@colixsystems/widget-sdk 0.192.0 → 0.193.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
@@ -53,7 +53,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
53
53
  | **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. |
54
54
  | **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, 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. |
55
55
  | **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. |
56
- | **DATASTORE** | `useDatastoreSubscription(table, handlers, options)` (optional: options) | `{ status }` — `"connecting" \| "live" \| "reconnecting" \| "fallback"` | `records(table).subscribe` — `datastore.read:<table>`. Live `onCreated` / `onUpdated` / `onDeleted` off the REQ-RT-07 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"`). Re-subscribes on the scope's VALUES, so a fresh object literal each render is fine. |
56
+ | **DATASTORE** | `useDatastoreSubscription(table, handlers, options)` (optional: options) | `{ status }` — `"connecting" \| "live" \| "reconnecting" \| "fallback"` | `records(table).subscribe` — `datastore.read:<table>`. Live `onCreated` / `onUpdated` / `onDeleted` off the REQ-RT-07 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. |
57
57
  | **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. |
58
58
  | **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) |
59
59
  | **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. |
@@ -77,7 +77,29 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
77
77
 
78
78
  ## Status
79
79
 
80
- `v0.192.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**.
80
+ `v0.193.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**.
81
+
82
+ ### What's new in 0.193.0 (contract 1.154.0)
83
+
84
+ **`useDatastoreSubscription` can keep a list of the user's own rows live.** Pass `{ scope: { kind: "granted" } }` and the hook streams every row of the table the signed-in user can read through a grant to them or to one of their groups — their conversations, their cases. Without it such a list had to poll: a whole-table subscribe needs read on every row, and one record scope per row runs out of subscriptions.
85
+
86
+ Two new handlers report changes to the set itself. `onAccessGained(record)` fires with the full row when a grant starts letting the user read it, for example when they are added to a conversation. `onAccessLost({ id })` fires when they can no longer read it, and no further event for that row arrives. Merge `onCreated` / `onUpdated` / `onAccessGained` into your list by `id`, and remove the row on `onDeleted` or `onAccessLost`:
87
+
88
+ ```jsx
89
+ const { data, refetch } = useDatastoreQuery(conversationsTableId);
90
+ const [live, setLive] = useState([]);
91
+ useEffect(() => setLive(data), [data]);
92
+ const upsert = (row) => setLive((list) => [row, ...list.filter((r) => r.id !== row.id)]);
93
+ const drop = ({ id }) => setLive((list) => list.filter((r) => r.id !== id));
94
+ const { status } = useDatastoreSubscription(
95
+ conversationsTableId,
96
+ { onCreated: upsert, onUpdated: upsert, onAccessGained: upsert, onDeleted: drop, onAccessLost: drop },
97
+ { scope: { kind: "granted" } },
98
+ );
99
+ // Poll with refetch() only while status === "fallback".
100
+ ```
101
+
102
+ A logged-out visitor's granted subscribe resolves to `{ status: "fallback" }`. Backed by datastore-client 0.21.0. Additive; works identically in the web Player and the native export.
81
103
 
82
104
  ### What's new in 0.192.0 (contract 1.153.0)
83
105
 
package/dist/contract.cjs CHANGED
@@ -2060,6 +2060,13 @@ const HOOKS = [
2060
2060
  "table governed by per-record grants, since a scope-less subscribe must " +
2061
2061
  "prove read-every-row. A `parent` scope requires the column be an " +
2062
2062
  "inheritAcl RELATION, else the subscribe resolves to " +
2063
+ "{ status: 'fallback' }. { kind: 'granted' } streams every row the " +
2064
+ "signed-in user can read through a grant to them or to one of their " +
2065
+ "groups — the way to keep a list of 'my conversations' or 'my cases' " +
2066
+ "live — and adds two handlers: onAccessGained(record) when a row " +
2067
+ "becomes readable (the full row) and onAccessLost({ id }) when it stops " +
2068
+ "being readable, after which no further event for it arrives. A " +
2069
+ "logged-out visitor's granted subscribe resolves to " +
2063
2070
  "{ status: 'fallback' }.",
2064
2071
  returnShape: {
2065
2072
  status:
@@ -5070,7 +5077,11 @@ const CONTRACT = deepFreeze({
5070
5077
  // `useAudioPlayer(uri)`, backed by the optional `device.audioRecorder` /
5071
5078
  // `device.audioPlayer` slices (MediaRecorder + <audio> on web, expo-audio
5072
5079
  // on native), with `AudioRecorderError` / `AudioPlayerError`.
5073
- version: "1.153.0",
5080
+ // 1.154.0: additive (sc-10589) — `useDatastoreSubscription` accepts
5081
+ // `scope: { kind: "granted" }` (the rows the signed-in user reads through
5082
+ // a user or group grant) and the `onAccessGained` / `onAccessLost`
5083
+ // handlers that report rows entering and leaving that set.
5084
+ version: "1.154.0",
5074
5085
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5075
5086
  hooks: HOOKS,
5076
5087
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -2060,6 +2060,13 @@ const HOOKS = [
2060
2060
  "table governed by per-record grants, since a scope-less subscribe must " +
2061
2061
  "prove read-every-row. A `parent` scope requires the column be an " +
2062
2062
  "inheritAcl RELATION, else the subscribe resolves to " +
2063
+ "{ status: 'fallback' }. { kind: 'granted' } streams every row the " +
2064
+ "signed-in user can read through a grant to them or to one of their " +
2065
+ "groups — the way to keep a list of 'my conversations' or 'my cases' " +
2066
+ "live — and adds two handlers: onAccessGained(record) when a row " +
2067
+ "becomes readable (the full row) and onAccessLost({ id }) when it stops " +
2068
+ "being readable, after which no further event for it arrives. A " +
2069
+ "logged-out visitor's granted subscribe resolves to " +
2063
2070
  "{ status: 'fallback' }.",
2064
2071
  returnShape: {
2065
2072
  status:
@@ -5066,7 +5073,11 @@ const CONTRACT = deepFreeze({
5066
5073
  // `useAudioPlayer(uri)`, backed by the optional `device.audioRecorder` /
5067
5074
  // `device.audioPlayer` slices (MediaRecorder + <audio> on web, expo-audio
5068
5075
  // on native), with `AudioRecorderError` / `AudioPlayerError`.
5069
- version: "1.153.0",
5076
+ // 1.154.0: additive (sc-10589) — `useDatastoreSubscription` accepts
5077
+ // `scope: { kind: "granted" }` (the rows the signed-in user reads through
5078
+ // a user or group grant) and the `onAccessGained` / `onAccessLost`
5079
+ // handlers that report rows entering and leaving that set.
5080
+ version: "1.154.0",
5070
5081
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5071
5082
  hooks: HOOKS,
5072
5083
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -3979,10 +3979,13 @@ export function useCanWrite(tableId, options) {
3979
3979
  * rather than crashing at render (widget-parity skill).
3980
3980
  *
3981
3981
  * @param {string} table Bound table id (falsy → no subscription, status "fallback").
3982
- * @param {{ onCreated?, onUpdated?, onDeleted? }} [handlers] Per-event callbacks; each receives the snake_case record.
3983
- * @param {{ fallbackAfterMs?: number, scope?: { kind: "record"|"parent", relation_column?: string, record_id: string } }} [options]
3982
+ * @param {{ onCreated?, onUpdated?, onDeleted?, onAccessGained?, onAccessLost? }} [handlers] Per-event callbacks; each receives the snake_case record.
3983
+ * @param {{ fallbackAfterMs?: number, scope?: { kind: "record"|"parent"|"granted", relation_column?: string, record_id?: string } }} [options]
3984
3984
  * `scope` narrows the stream below whole-table — required for a table
3985
3985
  * governed by per-record grants. Re-subscribes on its VALUES, not identity.
3986
+ * `{ kind: "granted" }` streams every row the signed-in user reads through
3987
+ * a user or group grant; `onAccessGained` / `onAccessLost` report rows
3988
+ * entering (the full row) and leaving (`{ id }`) that set.
3986
3989
  * @returns {{ status: "connecting" | "live" | "reconnecting" | "fallback" }}
3987
3990
  */
3988
3991
  export function useDatastoreSubscription(table, handlers, options) {
@@ -4051,6 +4054,8 @@ export function useDatastoreSubscription(table, handlers, options) {
4051
4054
  onCreated: (r) => handlersRef.current?.onCreated?.(r),
4052
4055
  onUpdated: (r) => handlersRef.current?.onUpdated?.(r),
4053
4056
  onDeleted: (r) => handlersRef.current?.onDeleted?.(r),
4057
+ onAccessGained: (r) => handlersRef.current?.onAccessGained?.(r),
4058
+ onAccessLost: (r) => handlersRef.current?.onAccessLost?.(r),
4054
4059
  onStatus: (s) => setStatus(s),
4055
4060
  },
4056
4061
  Object.keys(subscribeOptions).length > 0 ? subscribeOptions : undefined,
package/dist/index.d.ts CHANGED
@@ -1418,6 +1418,10 @@ export interface DatastoreSubscriptionHandlers {
1418
1418
  onCreated?: (record: Record<string, unknown>) => void;
1419
1419
  onUpdated?: (record: Record<string, unknown>) => void;
1420
1420
  onDeleted?: (record: Record<string, unknown>) => void;
1421
+ /** Granted scope: a row the user can now read, in full. */
1422
+ onAccessGained?: (record: Record<string, unknown>) => void;
1423
+ /** Granted scope: a row the user can no longer read; nothing follows it. */
1424
+ onAccessLost?: (record: { id: string }) => void;
1421
1425
  }
1422
1426
 
1423
1427
  // sc-6270: see SubscriptionScope in @colixsystems/datastore-client. A scoped
@@ -1425,7 +1429,8 @@ export interface DatastoreSubscriptionHandlers {
1425
1429
  // which is what lets a per-record-ACL table stream at all.
1426
1430
  export type DatastoreSubscriptionScope =
1427
1431
  | { kind: "record"; record_id: string }
1428
- | { kind: "parent"; relation_column: string; record_id: string };
1432
+ | { kind: "parent"; relation_column: string; record_id: string }
1433
+ | { kind: "granted" };
1429
1434
 
1430
1435
  export interface DatastoreSubscriptionOptions {
1431
1436
  fallbackAfterMs?: number;
@@ -1440,9 +1445,10 @@ export interface DatastoreSubscriptionOptions {
1440
1445
  * REST polling instead. Never throws — degrades to `{ status: "fallback" }` on
1441
1446
  * a host whose datastore client predates realtime.
1442
1447
  *
1443
- * `options.scope` narrows the stream to one record or to a parent's
1444
- * inheriting children; re-subscribes when the scope's values change, not on
1445
- * every render.
1448
+ * `options.scope` narrows the stream to one record, to a parent's
1449
+ * inheriting children, or (`{ kind: "granted" }`) to the rows the signed-in
1450
+ * user reads through a user or group grant; re-subscribes when the scope's
1451
+ * values change, not on every render.
1446
1452
  */
1447
1453
  export function useDatastoreSubscription(
1448
1454
  tableId: string | null | undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.192.0",
3
+ "version": "0.193.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
@@ -2060,6 +2060,13 @@ const HOOKS = [
2060
2060
  "table governed by per-record grants, since a scope-less subscribe must " +
2061
2061
  "prove read-every-row. A `parent` scope requires the column be an " +
2062
2062
  "inheritAcl RELATION, else the subscribe resolves to " +
2063
+ "{ status: 'fallback' }. { kind: 'granted' } streams every row the " +
2064
+ "signed-in user can read through a grant to them or to one of their " +
2065
+ "groups — the way to keep a list of 'my conversations' or 'my cases' " +
2066
+ "live — and adds two handlers: onAccessGained(record) when a row " +
2067
+ "becomes readable (the full row) and onAccessLost({ id }) when it stops " +
2068
+ "being readable, after which no further event for it arrives. A " +
2069
+ "logged-out visitor's granted subscribe resolves to " +
2063
2070
  "{ status: 'fallback' }.",
2064
2071
  returnShape: {
2065
2072
  status:
@@ -5066,7 +5073,11 @@ const CONTRACT = deepFreeze({
5066
5073
  // `useAudioPlayer(uri)`, backed by the optional `device.audioRecorder` /
5067
5074
  // `device.audioPlayer` slices (MediaRecorder + <audio> on web, expo-audio
5068
5075
  // on native), with `AudioRecorderError` / `AudioPlayerError`.
5069
- version: "1.153.0",
5076
+ // 1.154.0: additive (sc-10589) — `useDatastoreSubscription` accepts
5077
+ // `scope: { kind: "granted" }` (the rows the signed-in user reads through
5078
+ // a user or group grant) and the `onAccessGained` / `onAccessLost`
5079
+ // handlers that report rows entering and leaving that set.
5080
+ version: "1.154.0",
5070
5081
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5071
5082
  hooks: HOOKS,
5072
5083
  primitives: PRIMITIVES,