@colixsystems/widget-sdk 0.269.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 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>` |
@@ -59,7 +59,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
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
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>`. 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. |
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,31 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
86
86
 
87
87
  ## Status
88
88
 
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**.
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).
90
114
 
91
115
  ### What's new in 0.269.1 (contract 1.219.0)
92
116
 
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",
@@ -1941,7 +1942,12 @@ const HOOKS = [
1941
1942
  "meta.total), not just the page in `data`; null until known. A " +
1942
1943
  "successful useDatastoreMutation write to the table, from this widget " +
1943
1944
  "or any other on the screen, re-reads it on its own: never refetch or " +
1944
- "subscribe for your own writes.",
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.",
1945
1951
  returnShape: {
1946
1952
  data: "Record[]",
1947
1953
  total: "number | null",
@@ -1955,8 +1961,8 @@ const HOOKS = [
1955
1961
  },
1956
1962
  {
1957
1963
  name: "useDatastoreFigure",
1958
- signature: "useDatastoreFigure(tableId, { aggregate, column, filter })",
1959
- optionalArgs: ["aggregate", "column", "filter"],
1964
+ signature: "useDatastoreFigure(tableId, { aggregate, column, filter, live })",
1965
+ optionalArgs: ["aggregate", "column", "filter", "live"],
1960
1966
  description:
1961
1967
  "ONE live figure over a table, for a `<Stat>`: `aggregate` is " +
1962
1968
  "\"count\" (the default; the rows, or the rows `filter` matches), \"sum\" " +
@@ -1971,7 +1977,8 @@ const HOOKS = [
1971
1977
  "or average reads up to 1000 rows, skips blank cells rather than counting " +
1972
1978
  "them as zero, and past that reads \"≈ n\" (`truncated: true`). The stock " +
1973
1979
  "Stat widget computes its figure through this hook, so use it rather than " +
1974
- "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.",
1975
1982
  returnShape: {
1976
1983
  value: "number | null",
1977
1984
  text: "string | null // the formatted figure for <Stat value>",
@@ -1989,8 +1996,8 @@ const HOOKS = [
1989
1996
  {
1990
1997
  name: "useDatastoreAggregate",
1991
1998
  signature:
1992
- "useDatastoreAggregate(tableId, { groupBy, bucket, from, to, sumColumn, filter, order, limit })",
1993
- 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"],
1994
2001
  description:
1995
2002
  "Counts rows on the server over EVERY row the viewer can read, so it is " +
1996
2003
  "exact past the 1000-row page a list read returns — never group " +
@@ -2009,7 +2016,8 @@ const HOOKS = [
2009
2016
  "them out with a `nempty:` filter. Encrypted, array, file and relation " +
2010
2017
  "columns cannot be grouped (VALIDATION). `filter` takes the same shapes " +
2011
2018
  "as useDatastoreQuery's, created_at ranges included. Pass null as the " +
2012
- "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.",
2013
2021
  returnShape: {
2014
2022
  buckets:
2015
2023
  "Array<{ date, count, sum }> // date = the bucket's start, YYYY-MM-DD; sum is 0 without sumColumn",
@@ -2874,6 +2882,9 @@ const HOOKS = [
2874
2882
  signature: "useDatastoreSubscription(tableId, handlers, options)",
2875
2883
  optionalArgs: ["options"],
2876
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). " +
2877
2888
  "Subscribe to a table's realtime change stream via the injected " +
2878
2889
  "datastore-client at ctx.datastore.records(tableId).subscribe(...). " +
2879
2890
  "handlers is { onCreated?, onUpdated?, onDeleted? }; each receives the " +
@@ -6495,7 +6506,9 @@ const CONTRACT = deepFreeze({
6495
6506
  // not reach the script.
6496
6507
  // 1.219.0: behaviour (sc-14407) — a useDatastoreMutation write re-reads
6497
6508
  // every query, record, figure and aggregate on that table, app-wide.
6498
- version: "1.219.0",
6509
+ // 1.220.0: additive (sc-14423) — `live: true` on useDatastoreQuery,
6510
+ // useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
6511
+ version: "1.220.0",
6499
6512
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6500
6513
  hooks: HOOKS,
6501
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",
@@ -1938,7 +1939,12 @@ const HOOKS = [
1938
1939
  "meta.total), not just the page in `data`; null until known. A " +
1939
1940
  "successful useDatastoreMutation write to the table, from this widget " +
1940
1941
  "or any other on the screen, re-reads it on its own: never refetch or " +
1941
- "subscribe for your own writes.",
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.",
1942
1948
  returnShape: {
1943
1949
  data: "Record[]",
1944
1950
  total: "number | null",
@@ -1952,8 +1958,8 @@ const HOOKS = [
1952
1958
  },
1953
1959
  {
1954
1960
  name: "useDatastoreFigure",
1955
- signature: "useDatastoreFigure(tableId, { aggregate, column, filter })",
1956
- optionalArgs: ["aggregate", "column", "filter"],
1961
+ signature: "useDatastoreFigure(tableId, { aggregate, column, filter, live })",
1962
+ optionalArgs: ["aggregate", "column", "filter", "live"],
1957
1963
  description:
1958
1964
  "ONE live figure over a table, for a `<Stat>`: `aggregate` is " +
1959
1965
  "\"count\" (the default; the rows, or the rows `filter` matches), \"sum\" " +
@@ -1968,7 +1974,8 @@ const HOOKS = [
1968
1974
  "or average reads up to 1000 rows, skips blank cells rather than counting " +
1969
1975
  "them as zero, and past that reads \"≈ n\" (`truncated: true`). The stock " +
1970
1976
  "Stat widget computes its figure through this hook, so use it rather than " +
1971
- "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.",
1972
1979
  returnShape: {
1973
1980
  value: "number | null",
1974
1981
  text: "string | null // the formatted figure for <Stat value>",
@@ -1986,8 +1993,8 @@ const HOOKS = [
1986
1993
  {
1987
1994
  name: "useDatastoreAggregate",
1988
1995
  signature:
1989
- "useDatastoreAggregate(tableId, { groupBy, bucket, from, to, sumColumn, filter, order, limit })",
1990
- 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"],
1991
1998
  description:
1992
1999
  "Counts rows on the server over EVERY row the viewer can read, so it is " +
1993
2000
  "exact past the 1000-row page a list read returns — never group " +
@@ -2006,7 +2013,8 @@ const HOOKS = [
2006
2013
  "them out with a `nempty:` filter. Encrypted, array, file and relation " +
2007
2014
  "columns cannot be grouped (VALIDATION). `filter` takes the same shapes " +
2008
2015
  "as useDatastoreQuery's, created_at ranges included. Pass null as the " +
2009
- "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.",
2010
2018
  returnShape: {
2011
2019
  buckets:
2012
2020
  "Array<{ date, count, sum }> // date = the bucket's start, YYYY-MM-DD; sum is 0 without sumColumn",
@@ -2871,6 +2879,9 @@ const HOOKS = [
2871
2879
  signature: "useDatastoreSubscription(tableId, handlers, options)",
2872
2880
  optionalArgs: ["options"],
2873
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). " +
2874
2885
  "Subscribe to a table's realtime change stream via the injected " +
2875
2886
  "datastore-client at ctx.datastore.records(tableId).subscribe(...). " +
2876
2887
  "handlers is { onCreated?, onUpdated?, onDeleted? }; each receives the " +
@@ -6492,7 +6503,9 @@ const CONTRACT = deepFreeze({
6492
6503
  // not reach the script.
6493
6504
  // 1.219.0: behaviour (sc-14407) — a useDatastoreMutation write re-reads
6494
6505
  // every query, record, figure and aggregate on that table, app-wide.
6495
- version: "1.219.0",
6506
+ // 1.220.0: additive (sc-14423) — `live: true` on useDatastoreQuery,
6507
+ // useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
6508
+ version: "1.220.0",
6496
6509
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6497
6510
  hooks: HOOKS,
6498
6511
  primitives: PRIMITIVES,
@@ -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 rows = useDatastoreQuery(table || null, figureQuery(filter, aggregate));
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.
@@ -2894,6 +2895,8 @@ const queryResultKey = (table, queryKey) => JSON.stringify([table, queryKey]);
2894
2895
  // write through any widget re-reads every other widget's rows on that table.
2895
2896
  // A burst of writes folds into one re-read; a long run of them re-reads each
2896
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.
2897
2900
  const TABLE_WRITE_SETTLE_MS = 50;
2898
2901
  const TABLE_REREAD_INTERVAL_MS = 1000;
2899
2902
  const tableWrites = new Map();
@@ -2909,7 +2912,7 @@ export function stampFetchStart(ref) {
2909
2912
  function tableWritesOf(table) {
2910
2913
  let entry = tableWrites.get(table);
2911
2914
  if (!entry) {
2912
- entry = { listeners: new Set(), timer: null, lastRereadAt: -Infinity, wroteAt: 0 };
2915
+ entry = { listeners: new Set(), timer: null, lastRereadAt: -Infinity, wroteAt: 0, changedAt: 0 };
2913
2916
  tableWrites.set(table, entry);
2914
2917
  }
2915
2918
  return entry;
@@ -2924,7 +2927,7 @@ function rereadTable(table, entry) {
2924
2927
  entry.lastRereadAt = Date.now();
2925
2928
  for (const listener of Array.from(entry.listeners)) {
2926
2929
  try {
2927
- listener(entry.wroteAt);
2930
+ listener(entry);
2928
2931
  } catch (_e) {
2929
2932
  // A reader's failure must never reach the write that already succeeded.
2930
2933
  }
@@ -2932,10 +2935,11 @@ function rereadTable(table, entry) {
2932
2935
  forgetIfIdle(table, entry);
2933
2936
  }
2934
2937
 
2935
- function announceTableWrite(table) {
2938
+ function announceTableChange(table, { local }) {
2936
2939
  const entry = tableWritesOf(table);
2937
2940
  readWriteOrder += 1;
2938
- entry.wroteAt = readWriteOrder;
2941
+ entry.changedAt = readWriteOrder;
2942
+ if (local) entry.wroteAt = readWriteOrder;
2939
2943
  if (entry.timer) return;
2940
2944
  const wait = Math.max(TABLE_WRITE_SETTLE_MS, entry.lastRereadAt + TABLE_REREAD_INTERVAL_MS - Date.now());
2941
2945
  entry.timer = setTimeout(() => rereadTable(table, entry), wait);
@@ -2947,15 +2951,22 @@ function announceTableWrite(table) {
2947
2951
  * widget, unless the reader began a fetch of its own (stamped into
2948
2952
  * `fetchStartRef` by stampFetchStart) after that write. Keyed by the table
2949
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).
2950
2957
  */
2951
- export function useRereadOnWrite(table, reread, fetchStartRef) {
2958
+ export function useRereadOnWrite(table, reread, fetchStartRef, live = false) {
2959
+ const ctx = useContext(HostWidgetContext);
2952
2960
  const rereadRef = useRef(reread);
2953
2961
  rereadRef.current = reread;
2962
+ const liveRef = useRef(live);
2963
+ liveRef.current = live;
2954
2964
  useEffect(() => {
2955
2965
  if (!table) return undefined;
2956
2966
  const entry = tableWritesOf(table);
2957
- const listener = (wroteAt) => {
2958
- if (fetchStartRef && fetchStartRef.current > wroteAt) return;
2967
+ const listener = ({ wroteAt, changedAt }) => {
2968
+ const since = liveRef.current ? changedAt : wroteAt;
2969
+ if (!since || (fetchStartRef && fetchStartRef.current > since)) return;
2959
2970
  rereadRef.current();
2960
2971
  };
2961
2972
  entry.listeners.add(listener);
@@ -2965,6 +2976,24 @@ export function useRereadOnWrite(table, reread, fetchStartRef) {
2965
2976
  };
2966
2977
  // eslint-disable-next-line react-hooks/exhaustive-deps
2967
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]);
2968
2997
  }
2969
2998
  const recordResultKey = (table, recordId, includeCapabilities = false) =>
2970
2999
  JSON.stringify([table, recordId, includeCapabilities]);
@@ -3121,7 +3150,7 @@ export function useDatastoreQuery(table, query) {
3121
3150
  // sc-1179 — auto-subscribe to the page-level refresh tick so a
3122
3151
  // pull-to-refresh on mobile re-runs the query without per-widget code.
3123
3152
  useRefresh(refetch);
3124
- useRereadOnWrite(table, refetch, fetchStartRef);
3153
+ useRereadOnWrite(table, refetch, fetchStartRef, Boolean(query && query.live === true));
3125
3154
 
3126
3155
  const currentKey = table ? queryResultKey(table, queryKey) : null;
3127
3156
  const busy = Boolean(table) && (settled.key !== currentKey || inFlight);
@@ -3232,7 +3261,7 @@ export function useDatastoreRecord(table, recordId, options) {
3232
3261
 
3233
3262
  // sc-1179 — auto-subscribe to the page-level refresh tick.
3234
3263
  useRefresh(refetch);
3235
- useRereadOnWrite(recordId ? table : null, refetch, fetchStartRef);
3264
+ useRereadOnWrite(recordId ? table : null, refetch, fetchStartRef, options?.live === true);
3236
3265
 
3237
3266
  const currentKey =
3238
3267
  table && recordId ? recordResultKey(table, recordId, includeCapabilities) : null;
@@ -4146,7 +4175,7 @@ export function useDatastoreMutation(table) {
4146
4175
  } catch (err) {
4147
4176
  throw toDatastoreError(err);
4148
4177
  }
4149
- announceTableWrite(t);
4178
+ announceTableChange(t, { local: true });
4150
4179
  return result;
4151
4180
  }, []);
4152
4181
 
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
+ }
@@ -117,7 +117,7 @@ export function useDatastoreAggregate(table, spec) {
117
117
  toDatastoreError,
118
118
  fetchStartRef,
119
119
  );
120
- useRereadOnWrite(table, result.refetch, fetchStartRef);
120
+ useRereadOnWrite(table, result.refetch, fetchStartRef, Boolean(spec && spec.live === true));
121
121
 
122
122
  const { data, ...state } = result;
123
123
  const rows = data || [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.269.1",
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",
@@ -1938,7 +1939,12 @@ const HOOKS = [
1938
1939
  "meta.total), not just the page in `data`; null until known. A " +
1939
1940
  "successful useDatastoreMutation write to the table, from this widget " +
1940
1941
  "or any other on the screen, re-reads it on its own: never refetch or " +
1941
- "subscribe for your own writes.",
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.",
1942
1948
  returnShape: {
1943
1949
  data: "Record[]",
1944
1950
  total: "number | null",
@@ -1952,8 +1958,8 @@ const HOOKS = [
1952
1958
  },
1953
1959
  {
1954
1960
  name: "useDatastoreFigure",
1955
- signature: "useDatastoreFigure(tableId, { aggregate, column, filter })",
1956
- optionalArgs: ["aggregate", "column", "filter"],
1961
+ signature: "useDatastoreFigure(tableId, { aggregate, column, filter, live })",
1962
+ optionalArgs: ["aggregate", "column", "filter", "live"],
1957
1963
  description:
1958
1964
  "ONE live figure over a table, for a `<Stat>`: `aggregate` is " +
1959
1965
  "\"count\" (the default; the rows, or the rows `filter` matches), \"sum\" " +
@@ -1968,7 +1974,8 @@ const HOOKS = [
1968
1974
  "or average reads up to 1000 rows, skips blank cells rather than counting " +
1969
1975
  "them as zero, and past that reads \"≈ n\" (`truncated: true`). The stock " +
1970
1976
  "Stat widget computes its figure through this hook, so use it rather than " +
1971
- "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.",
1972
1979
  returnShape: {
1973
1980
  value: "number | null",
1974
1981
  text: "string | null // the formatted figure for <Stat value>",
@@ -1986,8 +1993,8 @@ const HOOKS = [
1986
1993
  {
1987
1994
  name: "useDatastoreAggregate",
1988
1995
  signature:
1989
- "useDatastoreAggregate(tableId, { groupBy, bucket, from, to, sumColumn, filter, order, limit })",
1990
- 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"],
1991
1998
  description:
1992
1999
  "Counts rows on the server over EVERY row the viewer can read, so it is " +
1993
2000
  "exact past the 1000-row page a list read returns — never group " +
@@ -2006,7 +2013,8 @@ const HOOKS = [
2006
2013
  "them out with a `nempty:` filter. Encrypted, array, file and relation " +
2007
2014
  "columns cannot be grouped (VALIDATION). `filter` takes the same shapes " +
2008
2015
  "as useDatastoreQuery's, created_at ranges included. Pass null as the " +
2009
- "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.",
2010
2018
  returnShape: {
2011
2019
  buckets:
2012
2020
  "Array<{ date, count, sum }> // date = the bucket's start, YYYY-MM-DD; sum is 0 without sumColumn",
@@ -2871,6 +2879,9 @@ const HOOKS = [
2871
2879
  signature: "useDatastoreSubscription(tableId, handlers, options)",
2872
2880
  optionalArgs: ["options"],
2873
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). " +
2874
2885
  "Subscribe to a table's realtime change stream via the injected " +
2875
2886
  "datastore-client at ctx.datastore.records(tableId).subscribe(...). " +
2876
2887
  "handlers is { onCreated?, onUpdated?, onDeleted? }; each receives the " +
@@ -6492,7 +6503,9 @@ const CONTRACT = deepFreeze({
6492
6503
  // not reach the script.
6493
6504
  // 1.219.0: behaviour (sc-14407) — a useDatastoreMutation write re-reads
6494
6505
  // every query, record, figure and aggregate on that table, app-wide.
6495
- version: "1.219.0",
6506
+ // 1.220.0: additive (sc-14423) — `live: true` on useDatastoreQuery,
6507
+ // useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
6508
+ version: "1.220.0",
6496
6509
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6497
6510
  hooks: HOOKS,
6498
6511
  primitives: PRIMITIVES,