@colixsystems/widget-sdk 0.268.0 → 0.269.1

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
@@ -58,7 +58,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
58
58
  | **DATASTORE** | `useInterpretDraft(tableId)` | `{ interpret, interpreting, error, result, available }` | `interpret(tableId, body)` — `datastore.read:<table>`. Turns ONE sentence a user typed ("walk at 11 am tomorrow") into DRAFT column values so a form can prefill itself. IMPERATIVE: call `interpret(text, { fields?, timeZone? })` from an event handler, never on mount. It DRAFTS and writes nothing — show the values for review, then submit through `useDatastoreMutation().create`. Resolves to `{ values, unresolved }`; `values` is keyed by column NAME (the shape `create()` takes) and `unresolved` names the fields the sentence did not state. Only text / number / boolean / date / datetime / array columns are drafted — `FILE`, `RELATION`, `USER` and `USER_GROUP` carry ids and are never guessed. Fails closed to an empty draft. **Every call spends the workspace's AI credits** and is rate-limited per actor, so call it once per user action (never on mount or in a render loop); once the workspace runs out the call is refused with a generic 429 — an app user is deliberately **not** told the workspace's billing state, since they have never heard of an AI credit and cannot buy one. Never surface a raw error to the person filling the form: say drafting is unavailable and keep every field editable by hand. `available` is false where the host brokers no interpreter. |
59
59
  | **AGENTS** | `useAgent(agentRef)` | `{ send, cancel, reset, messages, sending, resuming, error, available }` | `ctx.agents` — `ai.invoke:*`. Hold a conversation with one of the WORKSPACE's own AI agents (an author-written name + system prompt). Sending is IMPERATIVE: call `send(text)` from an event handler, never on mount — the first send opens the conversation, so a widget nobody types into costs nothing. On mount the hook DOES reopen the caller's most recent thread with this agent, so a refresh or a navigation keeps the conversation instead of starting a blank one; that resume only READS and spends no credit. `resuming` is true while it runs — render a loading state, not an empty thread. `messages` is the running transcript, oldest first. You **cannot** set or read the system prompt, replay an edited transcript, or pick a model: the server assembles every turn from the stored prompt plus the stored history, and `content` is the only thing that reaches the wire. `reset()` drops the thread (a "new chat" affordance), and the new thread is the one a later mount resumes. **Every send spends the workspace's AI credits** and is rate-limited per caller AND per workspace. Rejects with `AgentError`; branch on `.code` — `AI_QUOTA_EXCEEDED` (429) is the workspace's credit ceiling and will NOT clear on a retry, unlike `RATE_LIMITED`, so never offer "try again" for it, and `AGENT_DISABLED` (409) means the author turned it off, so hide the affordance. Never surface a raw error or the workspace's billing state to an app user. `available` is false when no agent is bound or the host brokers none — render a visible unbound state, never a blank box. An assistant message that used a tool also carries **`activity`** — what the agent DID with the user's data: the table it read (with the `search` text and `filters` it read with), or the table and columns it CHANGED (never the values, and never a row the permissions refused). **Render a write.** A reply saying "done!" is not evidence a row was written, and if the agent got it wrong the user has no other way to notice. A talk-only turn omits the key entirely. |
60
60
  | **ACTIONS** | `useAction(actionRef)` | `{ available, run, running, result, error }` | `ctx.actions` — `actions.run:*`. Run one of the workspace's Actions from an event handler (never on mount) and read the value it passed to `respond()`. The Action needs the app trigger. `available` is false when nothing is bound or the host brokers no client. Errors are `ActionError` with a stable `code`. |
61
- | **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*`. **To CLEAR a cell, pass it empty** — `update(id, { when: null })` (also `""` or `[]`) empties that cell and it reads back as `null` (`[]` on an array column). There is no separate clear call, so never write a sentinel such as `0` or a placeholder date to mean "no value". `false` and `0` are values, not clears, and a REQUIRED column cannot be cleared. |
61
+ | **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*`. A successful write re-reads every query, record, figure and aggregate on that table in every widget on the screen (a run of writes at most once a second, always once more after the last), so never refetch after it, and never write from an effect that re-runs on the data it reads unless a ref or Set marks what it already wrote. **To CLEAR a cell, pass it empty** — `update(id, { when: null })` (also `""` or `[]`) empties that cell and it reads back as `null` (`[]` on an array column). There is no separate clear call, so never write a sentinel such as `0` or a placeholder date to mean "no value". `false` and `0` are values, not clears, and a REQUIRED column cannot be cleared. |
62
62
  | **DATASTORE** | `useDatastoreSubscription(table, handlers, options)` (optional: options) | `{ status }` — `"connecting" \| "live" \| "reconnecting" \| "fallback"` | `records(table).subscribe` — `datastore.read:<table>`. Live `onCreated` / `onUpdated` / `onDeleted` off the realtime socket; never throws, resolving to `{ status: "fallback" }` so the widget polls instead. A whole-table subscribe is gated on read-EVERY-row, because one envelope reaches every subscriber of the table — so for a table governed by per-record grants pass `options.scope`: `{ kind: "record", record_id }` for one row, or `{ kind: "parent", relation_column, record_id }` for the rows whose RELATION column points at that parent (the column must carry `inheritAcl`, else the subscribe reports `"fallback"`). For a live list of the signed-in user's own rows pass `{ kind: "granted" }`: every row they read through a grant to them or one of their groups, plus `onAccessGained(record)` / `onAccessLost({ id })` as rows enter and leave that set. Re-subscribes on the scope's VALUES, so a fresh object literal each render is fine. |
63
63
  | **DATASTORE** | `useTyping(tableId, recordId)` | `{ typingUserIds, setTyping, status }` — status `"connecting" \| "live" \| "unavailable"` | `records(table).typing` — `datastore.read:<table>`. Who else is typing in one record, over the same socket as `useDatastoreSubscription`. Call `setTyping(true)` on every keystroke (throttled to one signal per 3 s; `false` is sent after 5 s idle) and `setTyping(false)` on send, clear or blur. `typingUserIds` lists the OTHER users' ids; each drops out 6 s after their last signal. Needs a signed-in user who can read the record. Never throws: an anonymous visitor, a refused subscription or a host whose client lacks `typing` reads `{ typingUserIds: [], status: "unavailable" }`. Re-subscribes when either id changes. |
64
64
  | **DATASTORE** | `useRecordPermissions(tableId, recordId)` | `{ permissions, loading, error, grant, revoke, update, refetch }` | `records(table).permissions(record).{ list, grant, update, revoke }` — `acl.write:records` (+ `can_grant` on the record) |
@@ -86,7 +86,19 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
86
86
 
87
87
  ## Status
88
88
 
89
- `v0.268.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**.
89
+ `v0.269.1` — 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**.
90
+
91
+ ### What's new in 0.269.1 (contract 1.219.0)
92
+
93
+ **Re-reads after a write are paced, and a write loop is refused.** A run of writes re-reads each widget at most once a second, and always once more after the last write; a reader that fetched on its own after the write is not re-read again. So a bulk loop of 500 saves costs each reader about one read a second, not 500, and the list still ends on the last save. An effect that saves and re-runs on the data it reads (or has no dependency list) would now save forever, because every save re-reads that data: keep the rows it already saved in a ref or a Set and return early, or save from the user's action. The publish checker refuses such an effect (`widget.writeLoopEffect`).
94
+
95
+ ### What's new in 0.269.0 (contract 1.219.0)
96
+
97
+ **A write refreshes every widget that shows the table.** After a successful `create`, `update` or `delete` through `useDatastoreMutation`, every `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreFigure` and `useDatastoreAggregate` on the same table re-reads itself, in the widget that wrote and in every other widget on the screen, on the web Player and in the native app alike. Drop the `refetch()` you called after your own writes and any subscription you wired for them; `useDatastoreSubscription` stays for changes other people make. Widgets must address the table the same way (its id, as a `tableRef` property binds it) for the refresh to reach them.
98
+
99
+ ### What's new in 0.268.1 (contract 1.218.0)
100
+
101
+ **The web `DateTimePicker`'s hour and minute columns no longer look broken.** The browser drew its scrollbar inside each narrow column, which cut the chosen hour and minute off on one side. The columns now hide the scrollbar and fade their top and bottom edges to show they scroll. The keyboard focus ring is drawn in full, and a row reached with the arrow keys stops clear of the fade. No API change; native keeps the operating system's picker.
90
102
 
91
103
  ### What's new in 0.268.0 (contract 1.218.0)
92
104
 
package/dist/contract.cjs CHANGED
@@ -1938,7 +1938,10 @@ const HOOKS = [
1938
1938
  "enforce, computed for the whole page in one lookup. Gate a row's " +
1939
1939
  "Edit on `row._capabilities?.can_write` and Delete on `can_delete`. " +
1940
1940
  "`total` is how many rows the query matches in all (the envelope's " +
1941
- "meta.total), not just the page in `data`; null until known.",
1941
+ "meta.total), not just the page in `data`; null until known. A " +
1942
+ "successful useDatastoreMutation write to the table, from this widget " +
1943
+ "or any other on the screen, re-reads it on its own: never refetch or " +
1944
+ "subscribe for your own writes.",
1942
1945
  returnShape: {
1943
1946
  data: "Record[]",
1944
1947
  total: "number | null",
@@ -2212,6 +2215,15 @@ const HOOKS = [
2212
2215
  {
2213
2216
  name: "useDatastoreMutation",
2214
2217
  signature: "useDatastoreMutation(tableId)",
2218
+ description:
2219
+ "Creates, updates (partial) and deletes rows. A successful write re-reads " +
2220
+ "every useDatastoreQuery, useDatastoreRecord, useDatastoreFigure and " +
2221
+ "useDatastoreAggregate on the same table reference, in every widget on " +
2222
+ "the screen, so no widget calls refetch() or subscribes for it. Readers " +
2223
+ "must name the table the same way (its id, as a tableRef property binds it). " +
2224
+ "A run of writes re-reads each reader at most once a second, always once " +
2225
+ "more after the last. Never write from a useEffect that re-runs on the data " +
2226
+ "it reads unless a ref or Set marks each row as already written: it loops.",
2215
2227
  returnShape: {
2216
2228
  create:
2217
2229
  "(record) => Promise<Record> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
@@ -6481,7 +6493,9 @@ const CONTRACT = deepFreeze({
6481
6493
  // 1.218.0: additive (sc-14224) — the actions client's `status(actionId)`;
6482
6494
  // `useAction().available` turns false when the server says a run would
6483
6495
  // not reach the script.
6484
- version: "1.218.0",
6496
+ // 1.219.0: behaviour (sc-14407) — a useDatastoreMutation write re-reads
6497
+ // every query, record, figure and aggregate on that table, app-wide.
6498
+ version: "1.219.0",
6485
6499
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6486
6500
  hooks: HOOKS,
6487
6501
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1935,7 +1935,10 @@ const HOOKS = [
1935
1935
  "enforce, computed for the whole page in one lookup. Gate a row's " +
1936
1936
  "Edit on `row._capabilities?.can_write` and Delete on `can_delete`. " +
1937
1937
  "`total` is how many rows the query matches in all (the envelope's " +
1938
- "meta.total), not just the page in `data`; null until known.",
1938
+ "meta.total), not just the page in `data`; null until known. A " +
1939
+ "successful useDatastoreMutation write to the table, from this widget " +
1940
+ "or any other on the screen, re-reads it on its own: never refetch or " +
1941
+ "subscribe for your own writes.",
1939
1942
  returnShape: {
1940
1943
  data: "Record[]",
1941
1944
  total: "number | null",
@@ -2209,6 +2212,15 @@ const HOOKS = [
2209
2212
  {
2210
2213
  name: "useDatastoreMutation",
2211
2214
  signature: "useDatastoreMutation(tableId)",
2215
+ description:
2216
+ "Creates, updates (partial) and deletes rows. A successful write re-reads " +
2217
+ "every useDatastoreQuery, useDatastoreRecord, useDatastoreFigure and " +
2218
+ "useDatastoreAggregate on the same table reference, in every widget on " +
2219
+ "the screen, so no widget calls refetch() or subscribes for it. Readers " +
2220
+ "must name the table the same way (its id, as a tableRef property binds it). " +
2221
+ "A run of writes re-reads each reader at most once a second, always once " +
2222
+ "more after the last. Never write from a useEffect that re-runs on the data " +
2223
+ "it reads unless a ref or Set marks each row as already written: it loops.",
2212
2224
  returnShape: {
2213
2225
  create:
2214
2226
  "(record) => Promise<Record> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
@@ -6478,7 +6490,9 @@ const CONTRACT = deepFreeze({
6478
6490
  // 1.218.0: additive (sc-14224) — the actions client's `status(actionId)`;
6479
6491
  // `useAction().available` turns false when the server says a run would
6480
6492
  // not reach the script.
6481
- version: "1.218.0",
6493
+ // 1.219.0: behaviour (sc-14407) — a useDatastoreMutation write re-reads
6494
+ // every query, record, figure and aggregate on that table, app-wide.
6495
+ version: "1.219.0",
6482
6496
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6483
6497
  hooks: HOOKS,
6484
6498
  primitives: PRIMITIVES,
@@ -14,6 +14,9 @@ const COLUMN_WIDTH = 64;
14
14
  const HOURS = Array.from({ length: 24 }, (_, i) => i);
15
15
  const MINUTES = Array.from({ length: 60 }, (_, i) => i);
16
16
  const LIST_KEYS = ["ArrowDown", "ArrowUp", "Home", "End"];
17
+ // Fades the edges in place of a scrollbar, which would clip cells.
18
+ const FADE = 24;
19
+ const EDGE_FADE = `linear-gradient(to bottom, transparent, #000 ${FADE}px, #000 calc(100% - ${FADE}px), transparent)`;
17
20
 
18
21
  /** @param {{View: any, Text: any, Pressable: any, ScrollView: any, Platform: any}} rn */
19
22
  export function makeTimeColumns(rn) {
@@ -27,7 +30,7 @@ export function makeTimeColumns(rn) {
27
30
  React.useEffect(() => {
28
31
  if (!mounted.current) {
29
32
  mounted.current = true;
30
- const top = values.indexOf(chosen) * (ITEM_HEIGHT + ITEM_GAP) - (height - ITEM_HEIGHT) / 2;
33
+ const top = FADE + values.indexOf(chosen) * (ITEM_HEIGHT + ITEM_GAP) - (height - ITEM_HEIGHT) / 2;
31
34
  if (scroller.current && typeof scroller.current.scrollTo === "function") {
32
35
  scroller.current.scrollTo({ y: Math.max(0, top), animated: false });
33
36
  }
@@ -76,7 +79,7 @@ export function makeTimeColumns(rn) {
76
79
  "aria-selected": selected,
77
80
  disabled: option.disabled,
78
81
  testID: testID ? `${testID}-${field}-${twoDigits(option.value)}` : undefined,
79
- style: cellStyle(t, { selected, current: false, disabled: option.disabled, width: COLUMN_WIDTH, height: ITEM_HEIGHT, round: true }),
82
+ style: cellStyle(t, { selected, current: false, disabled: option.disabled, width: COLUMN_WIDTH - 2 * ITEM_GAP, height: ITEM_HEIGHT, round: true }),
80
83
  },
81
84
  h(Text, { style: cellText(t, { selected }) }, twoDigits(option.value)),
82
85
  );
@@ -87,7 +90,15 @@ export function makeTimeColumns(rn) {
87
90
  h(Text, { "aria-hidden": true, style: { textAlign: "center", fontFamily: t.fontFamily, fontSize: t.helperSize, fontWeight: "500", color: t.helperColor } }, name),
88
91
  h(
89
92
  ScrollView,
90
- { ref: scroller, role: "listbox", accessibilityLabel: name, style: { height }, contentContainerStyle: { gap: ITEM_GAP, paddingVertical: ITEM_GAP } },
93
+ {
94
+ ref: scroller,
95
+ role: "listbox",
96
+ accessibilityLabel: name,
97
+ showsVerticalScrollIndicator: false,
98
+ style: { height, maskImage: EDGE_FADE, WebkitMaskImage: EDGE_FADE, scrollPaddingBlock: FADE },
99
+ // The first and last rows can scroll clear of the fade.
100
+ contentContainerStyle: { gap: ITEM_GAP, paddingVertical: FADE, paddingHorizontal: ITEM_GAP },
101
+ },
91
102
  ...options.map(item),
92
103
  ),
93
104
  );
package/dist/hooks.js CHANGED
@@ -2888,6 +2888,84 @@ function serializeQuery(query) {
2888
2888
  }
2889
2889
 
2890
2890
  const queryResultKey = (table, queryKey) => JSON.stringify([table, queryKey]);
2891
+
2892
+ // sc-14407 — every widget of a running app shares this module (the web host
2893
+ // hands custom widgets its own SDK; the native bundle has one copy), so a
2894
+ // write through any widget re-reads every other widget's rows on that table.
2895
+ // A burst of writes folds into one re-read; a long run of them re-reads each
2896
+ // widget at most once a second, and always once more after the last write.
2897
+ const TABLE_WRITE_SETTLE_MS = 50;
2898
+ const TABLE_REREAD_INTERVAL_MS = 1000;
2899
+ const tableWrites = new Map();
2900
+ // Orders writes and fetch starts: a fetch begun after a write already has its rows.
2901
+ let readWriteOrder = 0;
2902
+
2903
+ /** Internal: stamps the start of a reader's fetch, for useRereadOnWrite. */
2904
+ export function stampFetchStart(ref) {
2905
+ readWriteOrder += 1;
2906
+ ref.current = readWriteOrder;
2907
+ }
2908
+
2909
+ function tableWritesOf(table) {
2910
+ let entry = tableWrites.get(table);
2911
+ if (!entry) {
2912
+ entry = { listeners: new Set(), timer: null, lastRereadAt: -Infinity, wroteAt: 0 };
2913
+ tableWrites.set(table, entry);
2914
+ }
2915
+ return entry;
2916
+ }
2917
+
2918
+ function forgetIfIdle(table, entry) {
2919
+ if (entry.listeners.size === 0 && !entry.timer && tableWrites.get(table) === entry) tableWrites.delete(table);
2920
+ }
2921
+
2922
+ function rereadTable(table, entry) {
2923
+ entry.timer = null;
2924
+ entry.lastRereadAt = Date.now();
2925
+ for (const listener of Array.from(entry.listeners)) {
2926
+ try {
2927
+ listener(entry.wroteAt);
2928
+ } catch (_e) {
2929
+ // A reader's failure must never reach the write that already succeeded.
2930
+ }
2931
+ }
2932
+ forgetIfIdle(table, entry);
2933
+ }
2934
+
2935
+ function announceTableWrite(table) {
2936
+ const entry = tableWritesOf(table);
2937
+ readWriteOrder += 1;
2938
+ entry.wroteAt = readWriteOrder;
2939
+ if (entry.timer) return;
2940
+ const wait = Math.max(TABLE_WRITE_SETTLE_MS, entry.lastRereadAt + TABLE_REREAD_INTERVAL_MS - Date.now());
2941
+ entry.timer = setTimeout(() => rereadTable(table, entry), wait);
2942
+ }
2943
+
2944
+ /**
2945
+ * Internal: shared with stats-hooks.js, not re-exported by the index. Runs
2946
+ * `reread` after a successful useDatastoreMutation write to `table`, from any
2947
+ * widget, unless the reader began a fetch of its own (stamped into
2948
+ * `fetchStartRef` by stampFetchStart) after that write. Keyed by the table
2949
+ * reference exactly as the widgets pass it.
2950
+ */
2951
+ export function useRereadOnWrite(table, reread, fetchStartRef) {
2952
+ const rereadRef = useRef(reread);
2953
+ rereadRef.current = reread;
2954
+ useEffect(() => {
2955
+ if (!table) return undefined;
2956
+ const entry = tableWritesOf(table);
2957
+ const listener = (wroteAt) => {
2958
+ if (fetchStartRef && fetchStartRef.current > wroteAt) return;
2959
+ rereadRef.current();
2960
+ };
2961
+ entry.listeners.add(listener);
2962
+ return () => {
2963
+ entry.listeners.delete(listener);
2964
+ forgetIfIdle(table, entry);
2965
+ };
2966
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2967
+ }, [table]);
2968
+ }
2891
2969
  const recordResultKey = (table, recordId, includeCapabilities = false) =>
2892
2970
  JSON.stringify([table, recordId, includeCapabilities]);
2893
2971
 
@@ -2961,6 +3039,7 @@ export function useDatastoreQuery(table, query) {
2961
3039
  recordsRef.current = ctx.datastore.records;
2962
3040
 
2963
3041
  const runRef = useRef(0);
3042
+ const fetchStartRef = useRef(0);
2964
3043
 
2965
3044
  const doFetch = useCallback(async () => {
2966
3045
  const myRun = ++runRef.current;
@@ -2972,6 +3051,7 @@ export function useDatastoreQuery(table, query) {
2972
3051
  setSettled(NO_QUERY_RESULT);
2973
3052
  return;
2974
3053
  }
3054
+ stampFetchStart(fetchStartRef);
2975
3055
  const key = queryResultKey(t, serializeQuery(queryRef.current));
2976
3056
  setInFlight(true);
2977
3057
  try {
@@ -3041,6 +3121,7 @@ export function useDatastoreQuery(table, query) {
3041
3121
  // sc-1179 — auto-subscribe to the page-level refresh tick so a
3042
3122
  // pull-to-refresh on mobile re-runs the query without per-widget code.
3043
3123
  useRefresh(refetch);
3124
+ useRereadOnWrite(table, refetch, fetchStartRef);
3044
3125
 
3045
3126
  const currentKey = table ? queryResultKey(table, queryKey) : null;
3046
3127
  const busy = Boolean(table) && (settled.key !== currentKey || inFlight);
@@ -3106,6 +3187,7 @@ export function useDatastoreRecord(table, recordId, options) {
3106
3187
  recordsRef.current = ctx.datastore.records;
3107
3188
 
3108
3189
  const runRef = useRef(0);
3190
+ const fetchStartRef = useRef(0);
3109
3191
 
3110
3192
  const doFetch = useCallback(async () => {
3111
3193
  const myRun = ++runRef.current;
@@ -3116,6 +3198,7 @@ export function useDatastoreRecord(table, recordId, options) {
3116
3198
  setSettled(NO_RECORD_RESULT);
3117
3199
  return;
3118
3200
  }
3201
+ stampFetchStart(fetchStartRef);
3119
3202
  const key = recordResultKey(t, id, includeCapabilitiesRef.current);
3120
3203
  setInFlight(true);
3121
3204
  try {
@@ -3149,6 +3232,7 @@ export function useDatastoreRecord(table, recordId, options) {
3149
3232
 
3150
3233
  // sc-1179 — auto-subscribe to the page-level refresh tick.
3151
3234
  useRefresh(refetch);
3235
+ useRereadOnWrite(recordId ? table : null, refetch, fetchStartRef);
3152
3236
 
3153
3237
  const currentKey =
3154
3238
  table && recordId ? recordResultKey(table, recordId, includeCapabilities) : null;
@@ -4044,68 +4128,31 @@ export function useDatastoreMutation(table) {
4044
4128
  tableRef.current = table;
4045
4129
  recordsRef.current = ctx.datastore.records;
4046
4130
 
4047
- // Resolve records(table) lazily. Returns null when no table is bound so
4048
- // the callbacks reject with a clear VALIDATION error rather than letting
4049
- // the client's raw TypeError surface (or crashing at render).
4050
- const resolveNs = useCallback(() => {
4131
+ // Resolve records(table) lazily, so an unbound widget rejects with a clear
4132
+ // VALIDATION error instead of crashing at render. A successful write
4133
+ // re-reads every widget showing this table (sc-14407).
4134
+ const write = useCallback(async (verb, run) => {
4051
4135
  const t = tableRef.current;
4052
- if (!t) return null;
4053
- return recordsRef.current(t);
4136
+ if (!t) {
4137
+ throw new DatastoreError(
4138
+ "VALIDATION",
4139
+ `useDatastoreMutation: no table bound — set a source table before ${verb} a record.`,
4140
+ );
4141
+ }
4142
+ const ns = recordsRef.current(t);
4143
+ let result;
4144
+ try {
4145
+ result = await run(ns);
4146
+ } catch (err) {
4147
+ throw toDatastoreError(err);
4148
+ }
4149
+ announceTableWrite(t);
4150
+ return result;
4054
4151
  }, []);
4055
4152
 
4056
- const create = useCallback(
4057
- async (values) => {
4058
- const ns = resolveNs();
4059
- if (!ns) {
4060
- throw new DatastoreError(
4061
- "VALIDATION",
4062
- "useDatastoreMutation: no table bound — set a source table before creating a record.",
4063
- );
4064
- }
4065
- try {
4066
- return await ns.create(values);
4067
- } catch (err) {
4068
- throw toDatastoreError(err);
4069
- }
4070
- },
4071
- [resolveNs],
4072
- );
4073
-
4074
- const update = useCallback(
4075
- async (id, values) => {
4076
- const ns = resolveNs();
4077
- if (!ns) {
4078
- throw new DatastoreError(
4079
- "VALIDATION",
4080
- "useDatastoreMutation: no table bound — set a source table before updating a record.",
4081
- );
4082
- }
4083
- try {
4084
- return await ns.update(id, values);
4085
- } catch (err) {
4086
- throw toDatastoreError(err);
4087
- }
4088
- },
4089
- [resolveNs],
4090
- );
4091
-
4092
- const del = useCallback(
4093
- async (id) => {
4094
- const ns = resolveNs();
4095
- if (!ns) {
4096
- throw new DatastoreError(
4097
- "VALIDATION",
4098
- "useDatastoreMutation: no table bound — set a source table before deleting a record.",
4099
- );
4100
- }
4101
- try {
4102
- return await ns.delete(id);
4103
- } catch (err) {
4104
- throw toDatastoreError(err);
4105
- }
4106
- },
4107
- [resolveNs],
4108
- );
4153
+ const create = useCallback((values) => write("creating", (ns) => ns.create(values)), [write]);
4154
+ const update = useCallback((id, values) => write("updating", (ns) => ns.update(id, values)), [write]);
4155
+ const del = useCallback((id) => write("deleting", (ns) => ns.delete(id)), [write]);
4109
4156
 
4110
4157
  // sc-10268: one object while the methods are unchanged, so a widget can list
4111
4158
  // the mutation in a dependency array without re-running every render.
@@ -6,6 +6,8 @@ import {
6
6
  toDatastoreError,
7
7
  toDirectoryError,
8
8
  useRefresh,
9
+ stampFetchStart,
10
+ useRereadOnWrite,
9
11
  useWidgetContextOrThrow,
10
12
  } from "./hooks.js";
11
13
 
@@ -27,7 +29,7 @@ function serialize(value) {
27
29
  * keeps the previous data on screen (`fetching`); a new identity starts over
28
30
  * (`loading`). A null key is "nothing to read" and settles empty.
29
31
  */
30
- function useKeyedRead(read, identity, key, mapError) {
32
+ function useKeyedRead(read, identity, key, mapError, fetchStartRef) {
31
33
  const readRef = useRef(read);
32
34
  readRef.current = read;
33
35
  const runRef = useRef(0);
@@ -42,6 +44,7 @@ function useKeyedRead(read, identity, key, mapError) {
42
44
  return;
43
45
  }
44
46
  setFetching(true);
47
+ if (fetchStartRef) stampFetchStart(fetchStartRef);
45
48
  try {
46
49
  const data = await readRef.current();
47
50
  if (runRef.current === myRun) setSettled({ identity, key, data, error: null });
@@ -97,6 +100,7 @@ export function useDatastoreAggregate(table, spec) {
97
100
  ? serialize(byDate ? [groupBy, bucket, from, to, sumColumn, filter] : [groupBy, sumColumn, filter, order, limit])
98
101
  : null;
99
102
  const records = ctx.datastore.records;
103
+ const fetchStartRef = useRef(0);
100
104
 
101
105
  const result = useKeyedRead(
102
106
  async () => {
@@ -111,7 +115,9 @@ export function useDatastoreAggregate(table, spec) {
111
115
  table ? `${table}|${byDate ? "date" : "column"}` : null,
112
116
  key,
113
117
  toDatastoreError,
118
+ fetchStartRef,
114
119
  );
120
+ useRereadOnWrite(table, result.refetch, fetchStartRef);
115
121
 
116
122
  const { data, ...state } = result;
117
123
  const rows = data || [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.268.0",
3
+ "version": "0.269.1",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -1935,7 +1935,10 @@ const HOOKS = [
1935
1935
  "enforce, computed for the whole page in one lookup. Gate a row's " +
1936
1936
  "Edit on `row._capabilities?.can_write` and Delete on `can_delete`. " +
1937
1937
  "`total` is how many rows the query matches in all (the envelope's " +
1938
- "meta.total), not just the page in `data`; null until known.",
1938
+ "meta.total), not just the page in `data`; null until known. A " +
1939
+ "successful useDatastoreMutation write to the table, from this widget " +
1940
+ "or any other on the screen, re-reads it on its own: never refetch or " +
1941
+ "subscribe for your own writes.",
1939
1942
  returnShape: {
1940
1943
  data: "Record[]",
1941
1944
  total: "number | null",
@@ -2209,6 +2212,15 @@ const HOOKS = [
2209
2212
  {
2210
2213
  name: "useDatastoreMutation",
2211
2214
  signature: "useDatastoreMutation(tableId)",
2215
+ description:
2216
+ "Creates, updates (partial) and deletes rows. A successful write re-reads " +
2217
+ "every useDatastoreQuery, useDatastoreRecord, useDatastoreFigure and " +
2218
+ "useDatastoreAggregate on the same table reference, in every widget on " +
2219
+ "the screen, so no widget calls refetch() or subscribes for it. Readers " +
2220
+ "must name the table the same way (its id, as a tableRef property binds it). " +
2221
+ "A run of writes re-reads each reader at most once a second, always once " +
2222
+ "more after the last. Never write from a useEffect that re-runs on the data " +
2223
+ "it reads unless a ref or Set marks each row as already written: it loops.",
2212
2224
  returnShape: {
2213
2225
  create:
2214
2226
  "(record) => Promise<Record> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
@@ -6478,7 +6490,9 @@ const CONTRACT = deepFreeze({
6478
6490
  // 1.218.0: additive (sc-14224) — the actions client's `status(actionId)`;
6479
6491
  // `useAction().available` turns false when the server says a run would
6480
6492
  // not reach the script.
6481
- version: "1.218.0",
6493
+ // 1.219.0: behaviour (sc-14407) — a useDatastoreMutation write re-reads
6494
+ // every query, record, figure and aggregate on that table, app-wide.
6495
+ version: "1.219.0",
6482
6496
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6483
6497
  hooks: HOOKS,
6484
6498
  primitives: PRIMITIVES,