@colixsystems/widget-sdk 0.268.1 → 0.270.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 +36 -4
- package/dist/contract.cjs +36 -9
- package/dist/contract.js +36 -9
- package/dist/datastore-figure.js +3 -2
- package/dist/hooks.js +135 -59
- package/dist/index.d.ts +15 -1
- package/dist/live-table.js +105 -0
- package/dist/stats-hooks.js +7 -1
- package/package.json +1 -1
- package/src/contract.js +36 -9
package/README.md
CHANGED
|
@@ -50,7 +50,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
50
50
|
| **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
|
|
51
51
|
| **CORE** | `useLinkPreview(url)` | `{ preview, loading, error, available }` — `preview` is `{ url, title, description, site_name, image_url } \| null` | `ctx.linkPreviews.get` — no scope. A link's title, description, site name and image, fetched by the platform (the device never contacts the linked site). A page with nothing to show resolves with null fields, not an error — render the plain link. A non-http(s) `url` makes no request. Memoized for up to an hour across widgets; concurrent calls for one URL share a request. `error` is a `LinkPreviewError` (`VALIDATION` \| `AUTH_REQUIRED` \| `RATE_LIMITED` \| `NETWORK` \| `INTERNAL`). `available` is false on a host with no link-preview client |
|
|
52
52
|
| **CORE** | `useStableQuery(buildQuery)` | `T \| undefined` (whatever `buildQuery()` returns) | No context slice, no scope. Keeps `buildQuery()`'s result at a STABLE reference across renders when its (JSON-serialised) content hasn't changed, so `useDatastoreQuery(tableId, useStableQuery(() => ({...})))` replaces a hand-rolled `useMemo` with an easy-to-get-wrong deps array. Never throws: a `buildQuery` that itself throws degrades to a stable `undefined`; a result that can't be diffed (e.g. circular) degrades to "always a new reference". |
|
|
53
|
-
| **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options)` (optional: options) | `{ data, total, loading, fetching, error, refetch }` — `loading` only until the first result for the table; a changed query / refetch keeps `data` while `fetching`; `total` is the envelope's `meta.total` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*`. `options` is `{ filter, filterMode, q, sort, limit, offset, includeCapabilities }`, applied on the server: see *Querying records*. |
|
|
53
|
+
| **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options)` (optional: options) | `{ data, total, loading, fetching, error, refetch }` — `loading` only until the first result for the table; a changed query / refetch keeps `data` while `fetching`; `total` is the envelope's `meta.total` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*`. `options` is `{ filter, filterMode, q, sort, limit, offset, includeCapabilities, live }`, applied on the server: see *Querying records*. `live: true` re-reads when anyone else changes the table: see *Live lists*. |
|
|
54
54
|
| **DATASTORE** | `useDatastoreFigure(table, { aggregate, column, filter })` (optional: options) | `{ value, text, truncated, loading, fetching, error, refetch }` | `records(table).list` (a count reads the envelope total; a sum or average up to 1000 rows) — `datastore.read:*`. One count / sum / average for a `<Stat>`, computed exactly as the built-in Stat widget does. |
|
|
55
55
|
| **DATASTORE** | `useDatastoreRecord(table, id, options)` (optional: options) | `{ data, loading, fetching, error, refetch }` — a refetch of the same record keeps `data` while `fetching`; `{ includeCapabilities: true }` adds `data._capabilities` | `records(table).get` — `datastore.read:<table>` |
|
|
56
56
|
| **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
|
|
@@ -58,8 +58,8 @@ 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. |
|
|
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. |
|
|
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
|
+
| **DATASTORE** | `useDatastoreSubscription(table, handlers, options)` (optional: options) | `{ status }` — `"connecting" \| "live" \| "reconnecting" \| "fallback"` | `records(table).subscribe` — `datastore.read:<table>`. To keep a list, record or figure current, pass `live: true` to its read hook instead (*Live lists*); this hook is for acting on each change. 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) |
|
|
65
65
|
| **DATASTORE** | `useCanWrite(tableId, options)` (optional: options) | `{ canWrite, loading, error, refetch }` | `myPermissions(tableId, { recordId? })` — scope `datastore.read:<table>`. A FLOOR, not a full replacement for domain-specific write rules: answers "may this caller write", reading the same table-ACL answer the write endpoint enforces — so a table granting Create to Everyone answers `true` for a logged-out visitor, and this hook alone is the right gate for a widget meant to work without signing in. Pass `{ recordId }` for a per-row check. A widget whose own rule is MORE SPECIFIC than the table ACL (e.g. "only the assigned user may edit this row") must still hand-check that in addition. Pair with `useUser()` to also tell "not signed in" apart from "signed in but forbidden" — both resolve `canWrite: false` here. Falsy `tableId`, or a host that hasn't injected `myPermissions` (an older host), collapses to `{ canWrite: false, loading: false, error: null, refetch: async () => undefined }` rather than throwing. |
|
|
@@ -86,7 +86,39 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
86
86
|
|
|
87
87
|
## Status
|
|
88
88
|
|
|
89
|
-
`v0.
|
|
89
|
+
`v0.270.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**.
|
|
90
|
+
|
|
91
|
+
### What's new in 0.270.0 (contract 1.220.0)
|
|
92
|
+
|
|
93
|
+
**Live lists.** Add `live: true` to the object you already pass `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreFigure` or `useDatastoreAggregate`, and the read stays current when anyone else changes the table: `useDatastoreQuery(tableId, { sort: "created_at:desc", limit: 50, live: true })`. See *Live lists* below. The publish checker refuses a `setInterval` that re-reads records (`widget.refetchPoll`).
|
|
94
|
+
|
|
95
|
+
#### Live lists
|
|
96
|
+
|
|
97
|
+
- **What it does.** The read rides the table's realtime stream on the widget's one datastore socket (the same subscription `useDatastoreSubscription` uses), and each pushed change re-reads it the way a write does: a burst folds into one read, a busy table re-reads at most once a second and always once more after the last change. Without `live`, a read behaves exactly as before.
|
|
98
|
+
- **Permissions.** The server admits the whole-table stream only for a viewer who can read every row. Otherwise a signed-in viewer streams the rows granted to them, filtered per row, and a signed-out visitor gets no stream. The re-read is the ordinary permission-checked read, so a change to a row the viewer cannot read never changes what they see. Where nothing can stream (no socket, a refused visitor) the read re-checks every 30 seconds.
|
|
99
|
+
- **Table ids.** Pass the table id a `tableRef` property binds. A table name still reads, but only re-checks every 30 seconds.
|
|
100
|
+
- **No jumping.** A live re-read keeps `data` on screen with `loading: false`; render rows with `key={row.id}` so the ones already shown keep their place. For a newest-first list people scroll, hold new rows behind an "N new" button instead of pushing the list down:
|
|
101
|
+
|
|
102
|
+
```jsx
|
|
103
|
+
const { data, loading } = useDatastoreQuery(tableId, { sort: "created_at:desc", limit: 50, live: true });
|
|
104
|
+
const [seen, setSeen] = useState(null);
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (!loading && seen === null) setSeen(new Set(data.map((r) => r.id)));
|
|
107
|
+
}, [loading, data, seen]);
|
|
108
|
+
const shown = seen ? data.filter((r) => seen.has(r.id)) : data;
|
|
109
|
+
const fresh = seen ? data.length - shown.length : 0;
|
|
110
|
+
// {fresh > 0 ? <Button title={`${fresh} new`} onPress={() => setSeen(new Set(data.map((r) => r.id)))} /> : null}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
- **Never hand-poll.** Don't call `refetch()` from `setInterval`, and don't wire `useDatastoreSubscription` just to refresh a list. Keep that hook for acting on each change (a toast, a sound).
|
|
114
|
+
|
|
115
|
+
### What's new in 0.269.1 (contract 1.219.0)
|
|
116
|
+
|
|
117
|
+
**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`).
|
|
118
|
+
|
|
119
|
+
### What's new in 0.269.0 (contract 1.219.0)
|
|
120
|
+
|
|
121
|
+
**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.
|
|
90
122
|
|
|
91
123
|
### What's new in 0.268.1 (contract 1.218.0)
|
|
92
124
|
|
package/dist/contract.cjs
CHANGED
|
@@ -1354,7 +1354,8 @@ const HOOKS = [
|
|
|
1354
1354
|
"keeps it in `data` with loading false while `fetching` is true; a " +
|
|
1355
1355
|
"different table or record id resets to loading. " +
|
|
1356
1356
|
"`{ includeCapabilities: true }` adds `_capabilities` to the row, as " +
|
|
1357
|
-
"for useDatastoreQuery."
|
|
1357
|
+
"for useDatastoreQuery. `{ live: true }` re-reads it when anyone else " +
|
|
1358
|
+
"changes the table.",
|
|
1358
1359
|
returnShape: {
|
|
1359
1360
|
data: "Record | null",
|
|
1360
1361
|
loading: "boolean",
|
|
@@ -1938,7 +1939,15 @@ const HOOKS = [
|
|
|
1938
1939
|
"enforce, computed for the whole page in one lookup. Gate a row's " +
|
|
1939
1940
|
"Edit on `row._capabilities?.can_write` and Delete on `can_delete`. " +
|
|
1940
1941
|
"`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."
|
|
1942
|
+
"meta.total), not just the page in `data`; null until known. A " +
|
|
1943
|
+
"successful useDatastoreMutation write to the table, from this widget " +
|
|
1944
|
+
"or any other on the screen, re-reads it on its own: never refetch or " +
|
|
1945
|
+
"subscribe for your own writes. Add `live: true` to the options to also " +
|
|
1946
|
+
"re-read when anyone else changes the table (a pushed change, paced like " +
|
|
1947
|
+
"a write; the re-read is the same permission-checked read, and keeps the " +
|
|
1948
|
+
"rows on screen with loading false). Use it for lists other people " +
|
|
1949
|
+
"change: queues, bookings, chat, dashboards. Never poll with setInterval " +
|
|
1950
|
+
"or wire useDatastoreSubscription for that.",
|
|
1942
1951
|
returnShape: {
|
|
1943
1952
|
data: "Record[]",
|
|
1944
1953
|
total: "number | null",
|
|
@@ -1952,8 +1961,8 @@ const HOOKS = [
|
|
|
1952
1961
|
},
|
|
1953
1962
|
{
|
|
1954
1963
|
name: "useDatastoreFigure",
|
|
1955
|
-
signature: "useDatastoreFigure(tableId, { aggregate, column, filter })",
|
|
1956
|
-
optionalArgs: ["aggregate", "column", "filter"],
|
|
1964
|
+
signature: "useDatastoreFigure(tableId, { aggregate, column, filter, live })",
|
|
1965
|
+
optionalArgs: ["aggregate", "column", "filter", "live"],
|
|
1957
1966
|
description:
|
|
1958
1967
|
"ONE live figure over a table, for a `<Stat>`: `aggregate` is " +
|
|
1959
1968
|
"\"count\" (the default; the rows, or the rows `filter` matches), \"sum\" " +
|
|
@@ -1968,7 +1977,8 @@ const HOOKS = [
|
|
|
1968
1977
|
"or average reads up to 1000 rows, skips blank cells rather than counting " +
|
|
1969
1978
|
"them as zero, and past that reads \"≈ n\" (`truncated: true`). The stock " +
|
|
1970
1979
|
"Stat widget computes its figure through this hook, so use it rather than " +
|
|
1971
|
-
"reducing useDatastoreQuery rows by hand. Call it once per figure."
|
|
1980
|
+
"reducing useDatastoreQuery rows by hand. Call it once per figure. " +
|
|
1981
|
+
"`live: true` re-reads it when anyone else changes the table.",
|
|
1972
1982
|
returnShape: {
|
|
1973
1983
|
value: "number | null",
|
|
1974
1984
|
text: "string | null // the formatted figure for <Stat value>",
|
|
@@ -1986,8 +1996,8 @@ const HOOKS = [
|
|
|
1986
1996
|
{
|
|
1987
1997
|
name: "useDatastoreAggregate",
|
|
1988
1998
|
signature:
|
|
1989
|
-
"useDatastoreAggregate(tableId, { groupBy, bucket, from, to, sumColumn, filter, order, limit })",
|
|
1990
|
-
optionalArgs: ["groupBy", "bucket", "from", "to", "sumColumn", "filter", "order", "limit"],
|
|
1999
|
+
"useDatastoreAggregate(tableId, { groupBy, bucket, from, to, sumColumn, filter, order, limit, live })",
|
|
2000
|
+
optionalArgs: ["groupBy", "bucket", "from", "to", "sumColumn", "filter", "order", "limit", "live"],
|
|
1991
2001
|
description:
|
|
1992
2002
|
"Counts rows on the server over EVERY row the viewer can read, so it is " +
|
|
1993
2003
|
"exact past the 1000-row page a list read returns — never group " +
|
|
@@ -2006,7 +2016,8 @@ const HOOKS = [
|
|
|
2006
2016
|
"them out with a `nempty:` filter. Encrypted, array, file and relation " +
|
|
2007
2017
|
"columns cannot be grouped (VALIDATION). `filter` takes the same shapes " +
|
|
2008
2018
|
"as useDatastoreQuery's, created_at ranges included. Pass null as the " +
|
|
2009
|
-
"table until a bound table is known."
|
|
2019
|
+
"table until a bound table is known. `live: true` re-reads it when " +
|
|
2020
|
+
"anyone else changes the table.",
|
|
2010
2021
|
returnShape: {
|
|
2011
2022
|
buckets:
|
|
2012
2023
|
"Array<{ date, count, sum }> // date = the bucket's start, YYYY-MM-DD; sum is 0 without sumColumn",
|
|
@@ -2212,6 +2223,15 @@ const HOOKS = [
|
|
|
2212
2223
|
{
|
|
2213
2224
|
name: "useDatastoreMutation",
|
|
2214
2225
|
signature: "useDatastoreMutation(tableId)",
|
|
2226
|
+
description:
|
|
2227
|
+
"Creates, updates (partial) and deletes rows. A successful write re-reads " +
|
|
2228
|
+
"every useDatastoreQuery, useDatastoreRecord, useDatastoreFigure and " +
|
|
2229
|
+
"useDatastoreAggregate on the same table reference, in every widget on " +
|
|
2230
|
+
"the screen, so no widget calls refetch() or subscribes for it. Readers " +
|
|
2231
|
+
"must name the table the same way (its id, as a tableRef property binds it). " +
|
|
2232
|
+
"A run of writes re-reads each reader at most once a second, always once " +
|
|
2233
|
+
"more after the last. Never write from a useEffect that re-runs on the data " +
|
|
2234
|
+
"it reads unless a ref or Set marks each row as already written: it loops.",
|
|
2215
2235
|
returnShape: {
|
|
2216
2236
|
create:
|
|
2217
2237
|
"(record) => Promise<Record> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
|
|
@@ -2862,6 +2882,9 @@ const HOOKS = [
|
|
|
2862
2882
|
signature: "useDatastoreSubscription(tableId, handlers, options)",
|
|
2863
2883
|
optionalArgs: ["options"],
|
|
2864
2884
|
description:
|
|
2885
|
+
"To keep a list, record, figure or aggregate current, pass `live: true` " +
|
|
2886
|
+
"to its read hook instead; this hook is for acting on each change " +
|
|
2887
|
+
"(a toast, a sound, merging rows yourself). " +
|
|
2865
2888
|
"Subscribe to a table's realtime change stream via the injected " +
|
|
2866
2889
|
"datastore-client at ctx.datastore.records(tableId).subscribe(...). " +
|
|
2867
2890
|
"handlers is { onCreated?, onUpdated?, onDeleted? }; each receives the " +
|
|
@@ -6481,7 +6504,11 @@ const CONTRACT = deepFreeze({
|
|
|
6481
6504
|
// 1.218.0: additive (sc-14224) — the actions client's `status(actionId)`;
|
|
6482
6505
|
// `useAction().available` turns false when the server says a run would
|
|
6483
6506
|
// not reach the script.
|
|
6484
|
-
|
|
6507
|
+
// 1.219.0: behaviour (sc-14407) — a useDatastoreMutation write re-reads
|
|
6508
|
+
// every query, record, figure and aggregate on that table, app-wide.
|
|
6509
|
+
// 1.220.0: additive (sc-14423) — `live: true` on useDatastoreQuery,
|
|
6510
|
+
// useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
|
|
6511
|
+
version: "1.220.0",
|
|
6485
6512
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6486
6513
|
hooks: HOOKS,
|
|
6487
6514
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -1351,7 +1351,8 @@ const HOOKS = [
|
|
|
1351
1351
|
"keeps it in `data` with loading false while `fetching` is true; a " +
|
|
1352
1352
|
"different table or record id resets to loading. " +
|
|
1353
1353
|
"`{ includeCapabilities: true }` adds `_capabilities` to the row, as " +
|
|
1354
|
-
"for useDatastoreQuery."
|
|
1354
|
+
"for useDatastoreQuery. `{ live: true }` re-reads it when anyone else " +
|
|
1355
|
+
"changes the table.",
|
|
1355
1356
|
returnShape: {
|
|
1356
1357
|
data: "Record | null",
|
|
1357
1358
|
loading: "boolean",
|
|
@@ -1935,7 +1936,15 @@ const HOOKS = [
|
|
|
1935
1936
|
"enforce, computed for the whole page in one lookup. Gate a row's " +
|
|
1936
1937
|
"Edit on `row._capabilities?.can_write` and Delete on `can_delete`. " +
|
|
1937
1938
|
"`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."
|
|
1939
|
+
"meta.total), not just the page in `data`; null until known. A " +
|
|
1940
|
+
"successful useDatastoreMutation write to the table, from this widget " +
|
|
1941
|
+
"or any other on the screen, re-reads it on its own: never refetch or " +
|
|
1942
|
+
"subscribe for your own writes. Add `live: true` to the options to also " +
|
|
1943
|
+
"re-read when anyone else changes the table (a pushed change, paced like " +
|
|
1944
|
+
"a write; the re-read is the same permission-checked read, and keeps the " +
|
|
1945
|
+
"rows on screen with loading false). Use it for lists other people " +
|
|
1946
|
+
"change: queues, bookings, chat, dashboards. Never poll with setInterval " +
|
|
1947
|
+
"or wire useDatastoreSubscription for that.",
|
|
1939
1948
|
returnShape: {
|
|
1940
1949
|
data: "Record[]",
|
|
1941
1950
|
total: "number | null",
|
|
@@ -1949,8 +1958,8 @@ const HOOKS = [
|
|
|
1949
1958
|
},
|
|
1950
1959
|
{
|
|
1951
1960
|
name: "useDatastoreFigure",
|
|
1952
|
-
signature: "useDatastoreFigure(tableId, { aggregate, column, filter })",
|
|
1953
|
-
optionalArgs: ["aggregate", "column", "filter"],
|
|
1961
|
+
signature: "useDatastoreFigure(tableId, { aggregate, column, filter, live })",
|
|
1962
|
+
optionalArgs: ["aggregate", "column", "filter", "live"],
|
|
1954
1963
|
description:
|
|
1955
1964
|
"ONE live figure over a table, for a `<Stat>`: `aggregate` is " +
|
|
1956
1965
|
"\"count\" (the default; the rows, or the rows `filter` matches), \"sum\" " +
|
|
@@ -1965,7 +1974,8 @@ const HOOKS = [
|
|
|
1965
1974
|
"or average reads up to 1000 rows, skips blank cells rather than counting " +
|
|
1966
1975
|
"them as zero, and past that reads \"≈ n\" (`truncated: true`). The stock " +
|
|
1967
1976
|
"Stat widget computes its figure through this hook, so use it rather than " +
|
|
1968
|
-
"reducing useDatastoreQuery rows by hand. Call it once per figure."
|
|
1977
|
+
"reducing useDatastoreQuery rows by hand. Call it once per figure. " +
|
|
1978
|
+
"`live: true` re-reads it when anyone else changes the table.",
|
|
1969
1979
|
returnShape: {
|
|
1970
1980
|
value: "number | null",
|
|
1971
1981
|
text: "string | null // the formatted figure for <Stat value>",
|
|
@@ -1983,8 +1993,8 @@ const HOOKS = [
|
|
|
1983
1993
|
{
|
|
1984
1994
|
name: "useDatastoreAggregate",
|
|
1985
1995
|
signature:
|
|
1986
|
-
"useDatastoreAggregate(tableId, { groupBy, bucket, from, to, sumColumn, filter, order, limit })",
|
|
1987
|
-
optionalArgs: ["groupBy", "bucket", "from", "to", "sumColumn", "filter", "order", "limit"],
|
|
1996
|
+
"useDatastoreAggregate(tableId, { groupBy, bucket, from, to, sumColumn, filter, order, limit, live })",
|
|
1997
|
+
optionalArgs: ["groupBy", "bucket", "from", "to", "sumColumn", "filter", "order", "limit", "live"],
|
|
1988
1998
|
description:
|
|
1989
1999
|
"Counts rows on the server over EVERY row the viewer can read, so it is " +
|
|
1990
2000
|
"exact past the 1000-row page a list read returns — never group " +
|
|
@@ -2003,7 +2013,8 @@ const HOOKS = [
|
|
|
2003
2013
|
"them out with a `nempty:` filter. Encrypted, array, file and relation " +
|
|
2004
2014
|
"columns cannot be grouped (VALIDATION). `filter` takes the same shapes " +
|
|
2005
2015
|
"as useDatastoreQuery's, created_at ranges included. Pass null as the " +
|
|
2006
|
-
"table until a bound table is known."
|
|
2016
|
+
"table until a bound table is known. `live: true` re-reads it when " +
|
|
2017
|
+
"anyone else changes the table.",
|
|
2007
2018
|
returnShape: {
|
|
2008
2019
|
buckets:
|
|
2009
2020
|
"Array<{ date, count, sum }> // date = the bucket's start, YYYY-MM-DD; sum is 0 without sumColumn",
|
|
@@ -2209,6 +2220,15 @@ const HOOKS = [
|
|
|
2209
2220
|
{
|
|
2210
2221
|
name: "useDatastoreMutation",
|
|
2211
2222
|
signature: "useDatastoreMutation(tableId)",
|
|
2223
|
+
description:
|
|
2224
|
+
"Creates, updates (partial) and deletes rows. A successful write re-reads " +
|
|
2225
|
+
"every useDatastoreQuery, useDatastoreRecord, useDatastoreFigure and " +
|
|
2226
|
+
"useDatastoreAggregate on the same table reference, in every widget on " +
|
|
2227
|
+
"the screen, so no widget calls refetch() or subscribes for it. Readers " +
|
|
2228
|
+
"must name the table the same way (its id, as a tableRef property binds it). " +
|
|
2229
|
+
"A run of writes re-reads each reader at most once a second, always once " +
|
|
2230
|
+
"more after the last. Never write from a useEffect that re-runs on the data " +
|
|
2231
|
+
"it reads unless a ref or Set marks each row as already written: it loops.",
|
|
2212
2232
|
returnShape: {
|
|
2213
2233
|
create:
|
|
2214
2234
|
"(record) => Promise<Record> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
|
|
@@ -2859,6 +2879,9 @@ const HOOKS = [
|
|
|
2859
2879
|
signature: "useDatastoreSubscription(tableId, handlers, options)",
|
|
2860
2880
|
optionalArgs: ["options"],
|
|
2861
2881
|
description:
|
|
2882
|
+
"To keep a list, record, figure or aggregate current, pass `live: true` " +
|
|
2883
|
+
"to its read hook instead; this hook is for acting on each change " +
|
|
2884
|
+
"(a toast, a sound, merging rows yourself). " +
|
|
2862
2885
|
"Subscribe to a table's realtime change stream via the injected " +
|
|
2863
2886
|
"datastore-client at ctx.datastore.records(tableId).subscribe(...). " +
|
|
2864
2887
|
"handlers is { onCreated?, onUpdated?, onDeleted? }; each receives the " +
|
|
@@ -6478,7 +6501,11 @@ const CONTRACT = deepFreeze({
|
|
|
6478
6501
|
// 1.218.0: additive (sc-14224) — the actions client's `status(actionId)`;
|
|
6479
6502
|
// `useAction().available` turns false when the server says a run would
|
|
6480
6503
|
// not reach the script.
|
|
6481
|
-
|
|
6504
|
+
// 1.219.0: behaviour (sc-14407) — a useDatastoreMutation write re-reads
|
|
6505
|
+
// every query, record, figure and aggregate on that table, app-wide.
|
|
6506
|
+
// 1.220.0: additive (sc-14423) — `live: true` on useDatastoreQuery,
|
|
6507
|
+
// useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
|
|
6508
|
+
version: "1.220.0",
|
|
6482
6509
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6483
6510
|
hooks: HOOKS,
|
|
6484
6511
|
primitives: PRIMITIVES,
|
package/dist/datastore-figure.js
CHANGED
|
@@ -9,8 +9,9 @@ import { figureQuery, reduceFigure } from "./figure.js";
|
|
|
9
9
|
* `filter` keeps the previous figure on screen while `fetching`.
|
|
10
10
|
*/
|
|
11
11
|
export function useDatastoreFigure(table, options) {
|
|
12
|
-
const { aggregate, column, filter } = options || {};
|
|
13
|
-
const
|
|
12
|
+
const { aggregate, column, filter, live } = options || {};
|
|
13
|
+
const query = figureQuery(filter, aggregate);
|
|
14
|
+
const rows = useDatastoreQuery(table || null, live === true ? { ...query, live } : query);
|
|
14
15
|
const figure =
|
|
15
16
|
table && !rows.loading && !rows.error
|
|
16
17
|
? reduceFigure(rows.data, { aggregate, column }, rows.total)
|
package/dist/hooks.js
CHANGED
|
@@ -28,6 +28,7 @@ import React, {
|
|
|
28
28
|
useRef,
|
|
29
29
|
useState,
|
|
30
30
|
} from "react";
|
|
31
|
+
import { watchTableChanges } from "./live-table.js";
|
|
31
32
|
// REQ-L10N-WIDGET / REQ-L10N-SHARED: the translation key formats live in ONE
|
|
32
33
|
// place (contract.js). useI18n (lookup) and the backend seeder (write) both
|
|
33
34
|
// call them so the namespaced keys can never drift between the two sides.
|
|
@@ -2888,6 +2889,112 @@ function serializeQuery(query) {
|
|
|
2888
2889
|
}
|
|
2889
2890
|
|
|
2890
2891
|
const queryResultKey = (table, queryKey) => JSON.stringify([table, queryKey]);
|
|
2892
|
+
|
|
2893
|
+
// sc-14407 — every widget of a running app shares this module (the web host
|
|
2894
|
+
// hands custom widgets its own SDK; the native bundle has one copy), so a
|
|
2895
|
+
// write through any widget re-reads every other widget's rows on that table.
|
|
2896
|
+
// A burst of writes folds into one re-read; a long run of them re-reads each
|
|
2897
|
+
// widget at most once a second, and always once more after the last write.
|
|
2898
|
+
// sc-14423 — a change someone else made reaches only `live` readers, paced
|
|
2899
|
+
// the same way.
|
|
2900
|
+
const TABLE_WRITE_SETTLE_MS = 50;
|
|
2901
|
+
const TABLE_REREAD_INTERVAL_MS = 1000;
|
|
2902
|
+
const tableWrites = new Map();
|
|
2903
|
+
// Orders writes and fetch starts: a fetch begun after a write already has its rows.
|
|
2904
|
+
let readWriteOrder = 0;
|
|
2905
|
+
|
|
2906
|
+
/** Internal: stamps the start of a reader's fetch, for useRereadOnWrite. */
|
|
2907
|
+
export function stampFetchStart(ref) {
|
|
2908
|
+
readWriteOrder += 1;
|
|
2909
|
+
ref.current = readWriteOrder;
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
function tableWritesOf(table) {
|
|
2913
|
+
let entry = tableWrites.get(table);
|
|
2914
|
+
if (!entry) {
|
|
2915
|
+
entry = { listeners: new Set(), timer: null, lastRereadAt: -Infinity, wroteAt: 0, changedAt: 0 };
|
|
2916
|
+
tableWrites.set(table, entry);
|
|
2917
|
+
}
|
|
2918
|
+
return entry;
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
function forgetIfIdle(table, entry) {
|
|
2922
|
+
if (entry.listeners.size === 0 && !entry.timer && tableWrites.get(table) === entry) tableWrites.delete(table);
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
function rereadTable(table, entry) {
|
|
2926
|
+
entry.timer = null;
|
|
2927
|
+
entry.lastRereadAt = Date.now();
|
|
2928
|
+
for (const listener of Array.from(entry.listeners)) {
|
|
2929
|
+
try {
|
|
2930
|
+
listener(entry);
|
|
2931
|
+
} catch (_e) {
|
|
2932
|
+
// A reader's failure must never reach the write that already succeeded.
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
forgetIfIdle(table, entry);
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
function announceTableChange(table, { local }) {
|
|
2939
|
+
const entry = tableWritesOf(table);
|
|
2940
|
+
readWriteOrder += 1;
|
|
2941
|
+
entry.changedAt = readWriteOrder;
|
|
2942
|
+
if (local) entry.wroteAt = readWriteOrder;
|
|
2943
|
+
if (entry.timer) return;
|
|
2944
|
+
const wait = Math.max(TABLE_WRITE_SETTLE_MS, entry.lastRereadAt + TABLE_REREAD_INTERVAL_MS - Date.now());
|
|
2945
|
+
entry.timer = setTimeout(() => rereadTable(table, entry), wait);
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
/**
|
|
2949
|
+
* Internal: shared with stats-hooks.js, not re-exported by the index. Runs
|
|
2950
|
+
* `reread` after a successful useDatastoreMutation write to `table`, from any
|
|
2951
|
+
* widget, unless the reader began a fetch of its own (stamped into
|
|
2952
|
+
* `fetchStartRef` by stampFetchStart) after that write. Keyed by the table
|
|
2953
|
+
* reference exactly as the widgets pass it.
|
|
2954
|
+
*
|
|
2955
|
+
* sc-14423 — `live` also re-reads when anyone else changes the table, through
|
|
2956
|
+
* the widget's datastore client's realtime subscription (see live-table.js).
|
|
2957
|
+
*/
|
|
2958
|
+
export function useRereadOnWrite(table, reread, fetchStartRef, live = false) {
|
|
2959
|
+
const ctx = useContext(HostWidgetContext);
|
|
2960
|
+
const rereadRef = useRef(reread);
|
|
2961
|
+
rereadRef.current = reread;
|
|
2962
|
+
const liveRef = useRef(live);
|
|
2963
|
+
liveRef.current = live;
|
|
2964
|
+
useEffect(() => {
|
|
2965
|
+
if (!table) return undefined;
|
|
2966
|
+
const entry = tableWritesOf(table);
|
|
2967
|
+
const listener = ({ wroteAt, changedAt }) => {
|
|
2968
|
+
const since = liveRef.current ? changedAt : wroteAt;
|
|
2969
|
+
if (!since || (fetchStartRef && fetchStartRef.current > since)) return;
|
|
2970
|
+
rereadRef.current();
|
|
2971
|
+
};
|
|
2972
|
+
entry.listeners.add(listener);
|
|
2973
|
+
return () => {
|
|
2974
|
+
entry.listeners.delete(listener);
|
|
2975
|
+
forgetIfIdle(table, entry);
|
|
2976
|
+
};
|
|
2977
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2978
|
+
}, [table]);
|
|
2979
|
+
|
|
2980
|
+
const recordsRef = useRef(null);
|
|
2981
|
+
recordsRef.current = ctx && ctx.datastore && typeof ctx.datastore.records === "function" ? ctx.datastore.records : null;
|
|
2982
|
+
const signedIn = Boolean(ctx && ctx.user && ctx.user.id);
|
|
2983
|
+
useEffect(() => {
|
|
2984
|
+
if (!live || !table || !recordsRef.current) return undefined;
|
|
2985
|
+
let ns;
|
|
2986
|
+
try {
|
|
2987
|
+
ns = recordsRef.current(table);
|
|
2988
|
+
} catch (_e) {
|
|
2989
|
+
return undefined;
|
|
2990
|
+
}
|
|
2991
|
+
return watchTableChanges(ns, {
|
|
2992
|
+
tableId: table,
|
|
2993
|
+
signedIn,
|
|
2994
|
+
onChange: () => announceTableChange(table, { local: false }),
|
|
2995
|
+
});
|
|
2996
|
+
}, [table, live, signedIn]);
|
|
2997
|
+
}
|
|
2891
2998
|
const recordResultKey = (table, recordId, includeCapabilities = false) =>
|
|
2892
2999
|
JSON.stringify([table, recordId, includeCapabilities]);
|
|
2893
3000
|
|
|
@@ -2961,6 +3068,7 @@ export function useDatastoreQuery(table, query) {
|
|
|
2961
3068
|
recordsRef.current = ctx.datastore.records;
|
|
2962
3069
|
|
|
2963
3070
|
const runRef = useRef(0);
|
|
3071
|
+
const fetchStartRef = useRef(0);
|
|
2964
3072
|
|
|
2965
3073
|
const doFetch = useCallback(async () => {
|
|
2966
3074
|
const myRun = ++runRef.current;
|
|
@@ -2972,6 +3080,7 @@ export function useDatastoreQuery(table, query) {
|
|
|
2972
3080
|
setSettled(NO_QUERY_RESULT);
|
|
2973
3081
|
return;
|
|
2974
3082
|
}
|
|
3083
|
+
stampFetchStart(fetchStartRef);
|
|
2975
3084
|
const key = queryResultKey(t, serializeQuery(queryRef.current));
|
|
2976
3085
|
setInFlight(true);
|
|
2977
3086
|
try {
|
|
@@ -3041,6 +3150,7 @@ export function useDatastoreQuery(table, query) {
|
|
|
3041
3150
|
// sc-1179 — auto-subscribe to the page-level refresh tick so a
|
|
3042
3151
|
// pull-to-refresh on mobile re-runs the query without per-widget code.
|
|
3043
3152
|
useRefresh(refetch);
|
|
3153
|
+
useRereadOnWrite(table, refetch, fetchStartRef, Boolean(query && query.live === true));
|
|
3044
3154
|
|
|
3045
3155
|
const currentKey = table ? queryResultKey(table, queryKey) : null;
|
|
3046
3156
|
const busy = Boolean(table) && (settled.key !== currentKey || inFlight);
|
|
@@ -3106,6 +3216,7 @@ export function useDatastoreRecord(table, recordId, options) {
|
|
|
3106
3216
|
recordsRef.current = ctx.datastore.records;
|
|
3107
3217
|
|
|
3108
3218
|
const runRef = useRef(0);
|
|
3219
|
+
const fetchStartRef = useRef(0);
|
|
3109
3220
|
|
|
3110
3221
|
const doFetch = useCallback(async () => {
|
|
3111
3222
|
const myRun = ++runRef.current;
|
|
@@ -3116,6 +3227,7 @@ export function useDatastoreRecord(table, recordId, options) {
|
|
|
3116
3227
|
setSettled(NO_RECORD_RESULT);
|
|
3117
3228
|
return;
|
|
3118
3229
|
}
|
|
3230
|
+
stampFetchStart(fetchStartRef);
|
|
3119
3231
|
const key = recordResultKey(t, id, includeCapabilitiesRef.current);
|
|
3120
3232
|
setInFlight(true);
|
|
3121
3233
|
try {
|
|
@@ -3149,6 +3261,7 @@ export function useDatastoreRecord(table, recordId, options) {
|
|
|
3149
3261
|
|
|
3150
3262
|
// sc-1179 — auto-subscribe to the page-level refresh tick.
|
|
3151
3263
|
useRefresh(refetch);
|
|
3264
|
+
useRereadOnWrite(recordId ? table : null, refetch, fetchStartRef, options?.live === true);
|
|
3152
3265
|
|
|
3153
3266
|
const currentKey =
|
|
3154
3267
|
table && recordId ? recordResultKey(table, recordId, includeCapabilities) : null;
|
|
@@ -4044,68 +4157,31 @@ export function useDatastoreMutation(table) {
|
|
|
4044
4157
|
tableRef.current = table;
|
|
4045
4158
|
recordsRef.current = ctx.datastore.records;
|
|
4046
4159
|
|
|
4047
|
-
// Resolve records(table) lazily
|
|
4048
|
-
//
|
|
4049
|
-
//
|
|
4050
|
-
const
|
|
4160
|
+
// Resolve records(table) lazily, so an unbound widget rejects with a clear
|
|
4161
|
+
// VALIDATION error instead of crashing at render. A successful write
|
|
4162
|
+
// re-reads every widget showing this table (sc-14407).
|
|
4163
|
+
const write = useCallback(async (verb, run) => {
|
|
4051
4164
|
const t = tableRef.current;
|
|
4052
|
-
if (!t)
|
|
4053
|
-
|
|
4165
|
+
if (!t) {
|
|
4166
|
+
throw new DatastoreError(
|
|
4167
|
+
"VALIDATION",
|
|
4168
|
+
`useDatastoreMutation: no table bound — set a source table before ${verb} a record.`,
|
|
4169
|
+
);
|
|
4170
|
+
}
|
|
4171
|
+
const ns = recordsRef.current(t);
|
|
4172
|
+
let result;
|
|
4173
|
+
try {
|
|
4174
|
+
result = await run(ns);
|
|
4175
|
+
} catch (err) {
|
|
4176
|
+
throw toDatastoreError(err);
|
|
4177
|
+
}
|
|
4178
|
+
announceTableChange(t, { local: true });
|
|
4179
|
+
return result;
|
|
4054
4180
|
}, []);
|
|
4055
4181
|
|
|
4056
|
-
const create = useCallback(
|
|
4057
|
-
|
|
4058
|
-
|
|
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
|
-
);
|
|
4182
|
+
const create = useCallback((values) => write("creating", (ns) => ns.create(values)), [write]);
|
|
4183
|
+
const update = useCallback((id, values) => write("updating", (ns) => ns.update(id, values)), [write]);
|
|
4184
|
+
const del = useCallback((id) => write("deleting", (ns) => ns.delete(id)), [write]);
|
|
4109
4185
|
|
|
4110
4186
|
// sc-10268: one object while the methods are unchanged, so a widget can list
|
|
4111
4187
|
// the mutation in a dependency array without re-running every render.
|
package/dist/index.d.ts
CHANGED
|
@@ -1099,6 +1099,11 @@ export interface Query {
|
|
|
1099
1099
|
* that row. Gate a row's Edit / Delete on it.
|
|
1100
1100
|
*/
|
|
1101
1101
|
includeCapabilities?: boolean;
|
|
1102
|
+
/**
|
|
1103
|
+
* Re-read when anyone else changes the table, paced like a write and keeping
|
|
1104
|
+
* the rows on screen. For lists other people change; never poll by hand.
|
|
1105
|
+
*/
|
|
1106
|
+
live?: boolean;
|
|
1102
1107
|
}
|
|
1103
1108
|
|
|
1104
1109
|
/**
|
|
@@ -1148,6 +1153,11 @@ export interface FigureOptions {
|
|
|
1148
1153
|
filter?:
|
|
1149
1154
|
| Query["filter"]
|
|
1150
1155
|
| Array<{ column: string; operator: string; value?: unknown; valueMode?: string }>;
|
|
1156
|
+
/**
|
|
1157
|
+
* Re-read when anyone else changes the table, paced like a write and keeping
|
|
1158
|
+
* the figure on screen. Never poll by hand.
|
|
1159
|
+
*/
|
|
1160
|
+
live?: boolean;
|
|
1151
1161
|
}
|
|
1152
1162
|
|
|
1153
1163
|
export interface FigureResult {
|
|
@@ -1180,6 +1190,8 @@ export interface AggregateBucketSpec {
|
|
|
1180
1190
|
/** A plain number column to add up per bucket. */
|
|
1181
1191
|
sumColumn?: string;
|
|
1182
1192
|
filter?: FigureOptions["filter"];
|
|
1193
|
+
/** Re-read when anyone else changes the table. */
|
|
1194
|
+
live?: boolean;
|
|
1183
1195
|
}
|
|
1184
1196
|
|
|
1185
1197
|
export interface AggregateGroupSpec {
|
|
@@ -1192,6 +1204,8 @@ export interface AggregateGroupSpec {
|
|
|
1192
1204
|
order?: "count:desc" | "count:asc" | "sum:desc" | "sum:asc" | "group:asc" | "group:desc";
|
|
1193
1205
|
/** 1–1000. */
|
|
1194
1206
|
limit?: number;
|
|
1207
|
+
/** Re-read when anyone else changes the table. */
|
|
1208
|
+
live?: boolean;
|
|
1195
1209
|
}
|
|
1196
1210
|
|
|
1197
1211
|
export interface AggregateBucket {
|
|
@@ -1626,7 +1640,7 @@ export function useWidgetStyle(): Record<string, unknown>;
|
|
|
1626
1640
|
export function useDatastoreRecord(
|
|
1627
1641
|
tableId: string | null | undefined,
|
|
1628
1642
|
recordId: string | null | undefined,
|
|
1629
|
-
options?: { includeCapabilities?: boolean },
|
|
1643
|
+
options?: { includeCapabilities?: boolean; /** Re-read when anyone else changes the table. */ live?: boolean },
|
|
1630
1644
|
): {
|
|
1631
1645
|
data: unknown | null;
|
|
1632
1646
|
loading: boolean;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// sc-14423 — what keeps a `live: true` read current. No React, so the
|
|
2
|
+
// backend's end-to-end test drives this same code.
|
|
3
|
+
|
|
4
|
+
/** How often a live read re-reads while no realtime stream reaches it. */
|
|
5
|
+
export const LIVE_POLL_MS = 30000;
|
|
6
|
+
|
|
7
|
+
const GRANTED_SCOPE = Object.freeze({ kind: "granted" });
|
|
8
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Calls `onChange()` whenever someone may have changed the table behind `ns`
|
|
12
|
+
* (`records(tableId)`); it carries no row, the caller re-reads.
|
|
13
|
+
*
|
|
14
|
+
* Streams the whole table through the client's shared subscription. Until
|
|
15
|
+
* that goes live, a signed-in viewer also streams their granted rows (the
|
|
16
|
+
* server refuses the whole table to a viewer who cannot read every row).
|
|
17
|
+
* While nothing streams it polls; after any gap it reports one change.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} ns
|
|
20
|
+
* @param {{ tableId: string, signedIn?: boolean, onChange: () => void }} options
|
|
21
|
+
* @returns {() => void} stop
|
|
22
|
+
*/
|
|
23
|
+
export function watchTableChanges(ns, { tableId, signedIn = false, onChange }) {
|
|
24
|
+
const streamable = Boolean(ns && typeof ns.subscribe === "function" && UUID_RE.test(tableId || ""));
|
|
25
|
+
const statuses = new Map();
|
|
26
|
+
const releases = new Map();
|
|
27
|
+
let stopped = false;
|
|
28
|
+
let pollTimer = null;
|
|
29
|
+
let live = false;
|
|
30
|
+
let missed = false;
|
|
31
|
+
let tableWasLive = false;
|
|
32
|
+
|
|
33
|
+
const changed = () => {
|
|
34
|
+
if (!stopped) onChange();
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function settle() {
|
|
38
|
+
const all = [...statuses.values()];
|
|
39
|
+
const nowLive = all.includes("live");
|
|
40
|
+
if (nowLive && !live && missed) changed();
|
|
41
|
+
if (!nowLive && (live || all.includes("fallback"))) missed = true;
|
|
42
|
+
if (nowLive) missed = false;
|
|
43
|
+
live = nowLive;
|
|
44
|
+
if (!live && all.every((s) => s === "fallback")) {
|
|
45
|
+
if (!pollTimer) pollTimer = setInterval(changed, LIVE_POLL_MS);
|
|
46
|
+
} else if (pollTimer) {
|
|
47
|
+
clearInterval(pollTimer);
|
|
48
|
+
pollTimer = null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function close(name) {
|
|
53
|
+
releases.get(name)?.();
|
|
54
|
+
releases.delete(name);
|
|
55
|
+
statuses.delete(name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function update(name, status) {
|
|
59
|
+
if (stopped || !statuses.has(name)) return;
|
|
60
|
+
statuses.set(name, status);
|
|
61
|
+
if (name === "table" && status === "live") {
|
|
62
|
+
tableWasLive = true;
|
|
63
|
+
if (statuses.has("granted")) close("granted");
|
|
64
|
+
}
|
|
65
|
+
if (name === "table" && status === "fallback" && !tableWasLive && streamable && signedIn && !statuses.has("granted")) {
|
|
66
|
+
open("granted", GRANTED_SCOPE);
|
|
67
|
+
}
|
|
68
|
+
settle();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function open(name, scope) {
|
|
72
|
+
statuses.set(name, "connecting");
|
|
73
|
+
try {
|
|
74
|
+
const release = ns.subscribe(
|
|
75
|
+
{
|
|
76
|
+
onCreated: changed,
|
|
77
|
+
onUpdated: changed,
|
|
78
|
+
onDeleted: changed,
|
|
79
|
+
onAccessGained: changed,
|
|
80
|
+
onAccessLost: changed,
|
|
81
|
+
onStatus: (s) => update(name, s),
|
|
82
|
+
},
|
|
83
|
+
scope ? { scope } : undefined,
|
|
84
|
+
);
|
|
85
|
+
if (typeof release === "function" && statuses.has(name)) releases.set(name, release);
|
|
86
|
+
else if (typeof release === "function") release();
|
|
87
|
+
} catch (_e) {
|
|
88
|
+
update(name, "fallback");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (streamable) open("table", null);
|
|
93
|
+
else {
|
|
94
|
+
statuses.set("table", "fallback");
|
|
95
|
+
settle();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return function stop() {
|
|
99
|
+
stopped = true;
|
|
100
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
101
|
+
pollTimer = null;
|
|
102
|
+
for (const release of releases.values()) release();
|
|
103
|
+
releases.clear();
|
|
104
|
+
};
|
|
105
|
+
}
|
package/dist/stats-hooks.js
CHANGED
|
@@ -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, Boolean(spec && spec.live === true));
|
|
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.
|
|
3
|
+
"version": "0.270.0",
|
|
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
|
@@ -1351,7 +1351,8 @@ const HOOKS = [
|
|
|
1351
1351
|
"keeps it in `data` with loading false while `fetching` is true; a " +
|
|
1352
1352
|
"different table or record id resets to loading. " +
|
|
1353
1353
|
"`{ includeCapabilities: true }` adds `_capabilities` to the row, as " +
|
|
1354
|
-
"for useDatastoreQuery."
|
|
1354
|
+
"for useDatastoreQuery. `{ live: true }` re-reads it when anyone else " +
|
|
1355
|
+
"changes the table.",
|
|
1355
1356
|
returnShape: {
|
|
1356
1357
|
data: "Record | null",
|
|
1357
1358
|
loading: "boolean",
|
|
@@ -1935,7 +1936,15 @@ const HOOKS = [
|
|
|
1935
1936
|
"enforce, computed for the whole page in one lookup. Gate a row's " +
|
|
1936
1937
|
"Edit on `row._capabilities?.can_write` and Delete on `can_delete`. " +
|
|
1937
1938
|
"`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."
|
|
1939
|
+
"meta.total), not just the page in `data`; null until known. A " +
|
|
1940
|
+
"successful useDatastoreMutation write to the table, from this widget " +
|
|
1941
|
+
"or any other on the screen, re-reads it on its own: never refetch or " +
|
|
1942
|
+
"subscribe for your own writes. Add `live: true` to the options to also " +
|
|
1943
|
+
"re-read when anyone else changes the table (a pushed change, paced like " +
|
|
1944
|
+
"a write; the re-read is the same permission-checked read, and keeps the " +
|
|
1945
|
+
"rows on screen with loading false). Use it for lists other people " +
|
|
1946
|
+
"change: queues, bookings, chat, dashboards. Never poll with setInterval " +
|
|
1947
|
+
"or wire useDatastoreSubscription for that.",
|
|
1939
1948
|
returnShape: {
|
|
1940
1949
|
data: "Record[]",
|
|
1941
1950
|
total: "number | null",
|
|
@@ -1949,8 +1958,8 @@ const HOOKS = [
|
|
|
1949
1958
|
},
|
|
1950
1959
|
{
|
|
1951
1960
|
name: "useDatastoreFigure",
|
|
1952
|
-
signature: "useDatastoreFigure(tableId, { aggregate, column, filter })",
|
|
1953
|
-
optionalArgs: ["aggregate", "column", "filter"],
|
|
1961
|
+
signature: "useDatastoreFigure(tableId, { aggregate, column, filter, live })",
|
|
1962
|
+
optionalArgs: ["aggregate", "column", "filter", "live"],
|
|
1954
1963
|
description:
|
|
1955
1964
|
"ONE live figure over a table, for a `<Stat>`: `aggregate` is " +
|
|
1956
1965
|
"\"count\" (the default; the rows, or the rows `filter` matches), \"sum\" " +
|
|
@@ -1965,7 +1974,8 @@ const HOOKS = [
|
|
|
1965
1974
|
"or average reads up to 1000 rows, skips blank cells rather than counting " +
|
|
1966
1975
|
"them as zero, and past that reads \"≈ n\" (`truncated: true`). The stock " +
|
|
1967
1976
|
"Stat widget computes its figure through this hook, so use it rather than " +
|
|
1968
|
-
"reducing useDatastoreQuery rows by hand. Call it once per figure."
|
|
1977
|
+
"reducing useDatastoreQuery rows by hand. Call it once per figure. " +
|
|
1978
|
+
"`live: true` re-reads it when anyone else changes the table.",
|
|
1969
1979
|
returnShape: {
|
|
1970
1980
|
value: "number | null",
|
|
1971
1981
|
text: "string | null // the formatted figure for <Stat value>",
|
|
@@ -1983,8 +1993,8 @@ const HOOKS = [
|
|
|
1983
1993
|
{
|
|
1984
1994
|
name: "useDatastoreAggregate",
|
|
1985
1995
|
signature:
|
|
1986
|
-
"useDatastoreAggregate(tableId, { groupBy, bucket, from, to, sumColumn, filter, order, limit })",
|
|
1987
|
-
optionalArgs: ["groupBy", "bucket", "from", "to", "sumColumn", "filter", "order", "limit"],
|
|
1996
|
+
"useDatastoreAggregate(tableId, { groupBy, bucket, from, to, sumColumn, filter, order, limit, live })",
|
|
1997
|
+
optionalArgs: ["groupBy", "bucket", "from", "to", "sumColumn", "filter", "order", "limit", "live"],
|
|
1988
1998
|
description:
|
|
1989
1999
|
"Counts rows on the server over EVERY row the viewer can read, so it is " +
|
|
1990
2000
|
"exact past the 1000-row page a list read returns — never group " +
|
|
@@ -2003,7 +2013,8 @@ const HOOKS = [
|
|
|
2003
2013
|
"them out with a `nempty:` filter. Encrypted, array, file and relation " +
|
|
2004
2014
|
"columns cannot be grouped (VALIDATION). `filter` takes the same shapes " +
|
|
2005
2015
|
"as useDatastoreQuery's, created_at ranges included. Pass null as the " +
|
|
2006
|
-
"table until a bound table is known."
|
|
2016
|
+
"table until a bound table is known. `live: true` re-reads it when " +
|
|
2017
|
+
"anyone else changes the table.",
|
|
2007
2018
|
returnShape: {
|
|
2008
2019
|
buckets:
|
|
2009
2020
|
"Array<{ date, count, sum }> // date = the bucket's start, YYYY-MM-DD; sum is 0 without sumColumn",
|
|
@@ -2209,6 +2220,15 @@ const HOOKS = [
|
|
|
2209
2220
|
{
|
|
2210
2221
|
name: "useDatastoreMutation",
|
|
2211
2222
|
signature: "useDatastoreMutation(tableId)",
|
|
2223
|
+
description:
|
|
2224
|
+
"Creates, updates (partial) and deletes rows. A successful write re-reads " +
|
|
2225
|
+
"every useDatastoreQuery, useDatastoreRecord, useDatastoreFigure and " +
|
|
2226
|
+
"useDatastoreAggregate on the same table reference, in every widget on " +
|
|
2227
|
+
"the screen, so no widget calls refetch() or subscribes for it. Readers " +
|
|
2228
|
+
"must name the table the same way (its id, as a tableRef property binds it). " +
|
|
2229
|
+
"A run of writes re-reads each reader at most once a second, always once " +
|
|
2230
|
+
"more after the last. Never write from a useEffect that re-runs on the data " +
|
|
2231
|
+
"it reads unless a ref or Set marks each row as already written: it loops.",
|
|
2212
2232
|
returnShape: {
|
|
2213
2233
|
create:
|
|
2214
2234
|
"(record) => Promise<Record> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
|
|
@@ -2859,6 +2879,9 @@ const HOOKS = [
|
|
|
2859
2879
|
signature: "useDatastoreSubscription(tableId, handlers, options)",
|
|
2860
2880
|
optionalArgs: ["options"],
|
|
2861
2881
|
description:
|
|
2882
|
+
"To keep a list, record, figure or aggregate current, pass `live: true` " +
|
|
2883
|
+
"to its read hook instead; this hook is for acting on each change " +
|
|
2884
|
+
"(a toast, a sound, merging rows yourself). " +
|
|
2862
2885
|
"Subscribe to a table's realtime change stream via the injected " +
|
|
2863
2886
|
"datastore-client at ctx.datastore.records(tableId).subscribe(...). " +
|
|
2864
2887
|
"handlers is { onCreated?, onUpdated?, onDeleted? }; each receives the " +
|
|
@@ -6478,7 +6501,11 @@ const CONTRACT = deepFreeze({
|
|
|
6478
6501
|
// 1.218.0: additive (sc-14224) — the actions client's `status(actionId)`;
|
|
6479
6502
|
// `useAction().available` turns false when the server says a run would
|
|
6480
6503
|
// not reach the script.
|
|
6481
|
-
|
|
6504
|
+
// 1.219.0: behaviour (sc-14407) — a useDatastoreMutation write re-reads
|
|
6505
|
+
// every query, record, figure and aggregate on that table, app-wide.
|
|
6506
|
+
// 1.220.0: additive (sc-14423) — `live: true` on useDatastoreQuery,
|
|
6507
|
+
// useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
|
|
6508
|
+
version: "1.220.0",
|
|
6482
6509
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6483
6510
|
hooks: HOOKS,
|
|
6484
6511
|
primitives: PRIMITIVES,
|