@colixsystems/widget-sdk 0.23.0 → 0.24.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/dist/contract.cjs CHANGED
@@ -314,6 +314,31 @@ const HOOKS = [
314
314
  requiredContextSlice: ["datastore.records"],
315
315
  scopes: ["acl.write:records"],
316
316
  },
317
+ // REQ-RT-07 — realtime table subscription.
318
+ {
319
+ name: "useDatastoreSubscription",
320
+ signature: "useDatastoreSubscription(tableId, handlers, options?)",
321
+ description:
322
+ "Subscribe to a table's realtime change stream via the injected " +
323
+ "datastore-client at ctx.datastore.records(tableId).subscribe(...). " +
324
+ "handlers is { onCreated?, onUpdated?, onDeleted? }; each receives the " +
325
+ "snake_case record verbatim (same shape REST returns). The socket opens " +
326
+ "on mount, re-opens when tableId changes, and is torn down on unmount. " +
327
+ "Returns { status } where status is 'connecting' | 'live' | " +
328
+ "'reconnecting' | 'fallback'. The server gates each subscribe by the " +
329
+ "same read ACL REST honours; on an ACL reject, a missing WebSocket, or a " +
330
+ "host whose client predates realtime, the hook resolves to " +
331
+ "{ status: 'fallback' } WITHOUT throwing so the widget can poll instead. " +
332
+ "Reads the same datastore.read scope as useDatastoreQuery — declare " +
333
+ "datastore.read for the table you subscribe to. Pair with " +
334
+ "useDatastoreQuery for the initial load and merge the streamed envelopes.",
335
+ returnShape: {
336
+ status:
337
+ "'connecting' | 'live' | 'reconnecting' | 'fallback' // transport state; 'fallback' → poll",
338
+ },
339
+ requiredContextSlice: ["datastore.records"],
340
+ scopes: ["datastore.read:<table>"],
341
+ },
317
342
  // REQ-WSDK-PLATFORM §6 — Tier A SDK hooks. Mirror of contract.js.
318
343
  {
319
344
  name: "useClipboard",
@@ -1136,7 +1161,15 @@ const CONTRACT = deepFreeze({
1136
1161
  // is additive — minor bump on the pre-1.0 channel. The compiler pins the
1137
1162
  // native-module members in the exported Expo app's package.json and now
1138
1163
  // always emits the react-native-reanimated/plugin (CLAUDE.md §3 parity).
1139
- version: "1.13.0",
1164
+ //
1165
+ // 1.14.0: additive (REQ-RT-07) — new `useDatastoreSubscription(tableId,
1166
+ // handlers, options?)` hook reading ctx.datastore.records(t).subscribe.
1167
+ // Backed by the datastore-client 0.6.0 `subscribe` method (WebSocket to
1168
+ // `/datastore/ws`). Safe-by-default: resolves to { status: 'fallback' }
1169
+ // when the host's client predates realtime or no WebSocket is available,
1170
+ // so widgets degrade to polling on both platforms. No existing hook,
1171
+ // primitive, or contract field changed — minor bump on the pre-1.0 channel.
1172
+ version: "1.14.0",
1140
1173
  hooks: HOOKS,
1141
1174
  primitives: PRIMITIVES,
1142
1175
  manifestSchema: MANIFEST_SCHEMA,
package/dist/contract.js CHANGED
@@ -321,6 +321,31 @@ const HOOKS = [
321
321
  requiredContextSlice: ["datastore.records"],
322
322
  scopes: ["acl.write:records"],
323
323
  },
324
+ // REQ-RT-07 — realtime table subscription.
325
+ {
326
+ name: "useDatastoreSubscription",
327
+ signature: "useDatastoreSubscription(tableId, handlers, options?)",
328
+ description:
329
+ "Subscribe to a table's realtime change stream via the injected " +
330
+ "datastore-client at ctx.datastore.records(tableId).subscribe(...). " +
331
+ "handlers is { onCreated?, onUpdated?, onDeleted? }; each receives the " +
332
+ "snake_case record verbatim (same shape REST returns). The socket opens " +
333
+ "on mount, re-opens when tableId changes, and is torn down on unmount. " +
334
+ "Returns { status } where status is 'connecting' | 'live' | " +
335
+ "'reconnecting' | 'fallback'. The server gates each subscribe by the " +
336
+ "same read ACL REST honours; on an ACL reject, a missing WebSocket, or a " +
337
+ "host whose client predates realtime, the hook resolves to " +
338
+ "{ status: 'fallback' } WITHOUT throwing so the widget can poll instead. " +
339
+ "Reads the same datastore.read scope as useDatastoreQuery — declare " +
340
+ "datastore.read for the table you subscribe to. Pair with " +
341
+ "useDatastoreQuery for the initial load and merge the streamed envelopes.",
342
+ returnShape: {
343
+ status:
344
+ "'connecting' | 'live' | 'reconnecting' | 'fallback' // transport state; 'fallback' → poll",
345
+ },
346
+ requiredContextSlice: ["datastore.records"],
347
+ scopes: ["datastore.read:<table>"],
348
+ },
324
349
  // REQ-WSDK-PLATFORM §6 — Tier A SDK hooks.
325
350
  {
326
351
  name: "useClipboard",
@@ -1089,7 +1114,15 @@ const CONTRACT = deepFreeze({
1089
1114
  // is additive — minor bump on the pre-1.0 channel. The compiler pins the
1090
1115
  // native-module members in the exported Expo app's package.json and now
1091
1116
  // always emits the react-native-reanimated/plugin (CLAUDE.md §3 parity).
1092
- version: "1.13.0",
1117
+ //
1118
+ // 1.14.0: additive (REQ-RT-07) — new `useDatastoreSubscription(tableId,
1119
+ // handlers, options?)` hook reading ctx.datastore.records(t).subscribe.
1120
+ // Backed by the datastore-client 0.6.0 `subscribe` method (WebSocket to
1121
+ // `/datastore/ws`). Safe-by-default: resolves to { status: 'fallback' }
1122
+ // when the host's client predates realtime or no WebSocket is available,
1123
+ // so widgets degrade to polling on both platforms. No existing hook,
1124
+ // primitive, or contract field changed — minor bump on the pre-1.0 channel.
1125
+ version: "1.14.0",
1093
1126
  hooks: HOOKS,
1094
1127
  primitives: PRIMITIVES,
1095
1128
  manifestSchema: MANIFEST_SCHEMA,
package/dist/hooks.js CHANGED
@@ -921,6 +921,93 @@ export function useRecordPermissions(tableId, recordId) {
921
921
  return { permissions, loading, error, grant, revoke, update, refetch };
922
922
  }
923
923
 
924
+ /**
925
+ * REQ-RT-07: subscribe to a table's realtime change stream. Reads
926
+ * `ctx.datastore.records(table).subscribe({ onCreated, onUpdated, onDeleted,
927
+ * onStatus })` and wires it to a React lifecycle: the socket is opened on
928
+ * mount (and re-opened when `table` changes) and torn down on unmount via the
929
+ * unsubscribe function the client returns. The author's `handlers` callbacks
930
+ * are held in a ref so re-renders with fresh callback identities never tear
931
+ * the socket down — only a `table` change does.
932
+ *
933
+ * Returns `{ status }` where status is "connecting" | "live" | "reconnecting"
934
+ * | "fallback". A widget typically pairs this with `useDatastoreQuery` for the
935
+ * initial load and merges the streamed create/update/delete envelopes into its
936
+ * local list; when `status === "fallback"` (no socket support on the host, the
937
+ * subscribe was ACL-rejected, or the connect timed out) the widget should run
938
+ * its own REST polling instead.
939
+ *
940
+ * Cross-platform + safe-by-default: if the host's datastore client doesn't
941
+ * expose `subscribe` (an older host, or a runtime with no WebSocket), the hook
942
+ * resolves to `{ status: "fallback" }` WITHOUT throwing, so a widget that
943
+ * subscribes degrades to polling on both the web Player and the native export
944
+ * rather than crashing at render (CLAUDE.md §11).
945
+ *
946
+ * @param {string} table Bound table id (falsy → no subscription, status "fallback").
947
+ * @param {{ onCreated?, onUpdated?, onDeleted? }} [handlers] Per-event callbacks; each receives the snake_case record.
948
+ * @param {{ fallbackAfterMs?: number }} [options]
949
+ * @returns {{ status: "connecting" | "live" | "reconnecting" | "fallback" }}
950
+ */
951
+ export function useDatastoreSubscription(table, handlers, options) {
952
+ const ctx = useWidgetContextOrThrow("useDatastoreSubscription");
953
+ const [status, setStatus] = useState("connecting");
954
+
955
+ // Author callbacks live in a ref so a new closure each render does not
956
+ // re-run the effect (which would tear down + re-open the socket).
957
+ const handlersRef = useRef(handlers);
958
+ handlersRef.current = handlers;
959
+ // `ctx` is a fresh object identity per host render; hold the records
960
+ // factory in a ref so the effect depends only on `table`.
961
+ const recordsRef = useRef(
962
+ ctx.datastore && typeof ctx.datastore.records === "function"
963
+ ? ctx.datastore.records
964
+ : null,
965
+ );
966
+ recordsRef.current =
967
+ ctx.datastore && typeof ctx.datastore.records === "function"
968
+ ? ctx.datastore.records
969
+ : null;
970
+
971
+ const fallbackAfterMs =
972
+ options && Number.isFinite(options.fallbackAfterMs)
973
+ ? options.fallbackAfterMs
974
+ : undefined;
975
+
976
+ useEffect(() => {
977
+ if (!table || typeof recordsRef.current !== "function") {
978
+ setStatus("fallback");
979
+ return undefined;
980
+ }
981
+ let ns;
982
+ try {
983
+ ns = recordsRef.current(table);
984
+ } catch (_) {
985
+ setStatus("fallback");
986
+ return undefined;
987
+ }
988
+ if (!ns || typeof ns.subscribe !== "function") {
989
+ // Host's datastore client predates realtime — degrade to polling.
990
+ setStatus("fallback");
991
+ return undefined;
992
+ }
993
+ const stop = ns.subscribe(
994
+ {
995
+ onCreated: (r) => handlersRef.current?.onCreated?.(r),
996
+ onUpdated: (r) => handlersRef.current?.onUpdated?.(r),
997
+ onDeleted: (r) => handlersRef.current?.onDeleted?.(r),
998
+ onStatus: (s) => setStatus(s),
999
+ },
1000
+ fallbackAfterMs != null ? { fallbackAfterMs } : undefined,
1001
+ );
1002
+ return () => {
1003
+ if (typeof stop === "function") stop();
1004
+ };
1005
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1006
+ }, [table, fallbackAfterMs]);
1007
+
1008
+ return { status };
1009
+ }
1010
+
924
1011
  /* ============================================================================
925
1012
  * FILES CLIENT — ctx.files (@colixsystems/files-client)
926
1013
  *
package/dist/index.d.ts CHANGED
@@ -624,6 +624,37 @@ export function useDatastoreSchema(
624
624
  tableId: string | null | undefined,
625
625
  ): SchemaResult;
626
626
 
627
+ // REQ-RT-07 realtime subscription transport state.
628
+ export type DatastoreSubscriptionStatus =
629
+ | "connecting"
630
+ | "live"
631
+ | "reconnecting"
632
+ | "fallback";
633
+
634
+ export interface DatastoreSubscriptionHandlers {
635
+ onCreated?: (record: Record<string, unknown>) => void;
636
+ onUpdated?: (record: Record<string, unknown>) => void;
637
+ onDeleted?: (record: Record<string, unknown>) => void;
638
+ }
639
+
640
+ export interface DatastoreSubscriptionOptions {
641
+ fallbackAfterMs?: number;
642
+ }
643
+
644
+ /**
645
+ * REQ-RT-07: subscribe to a table's realtime change stream via
646
+ * `ctx.datastore.records(tableId).subscribe(...)`. Opens on mount, re-opens on
647
+ * `tableId` change, tears down on unmount. Returns `{ status }`; when status
648
+ * is `"fallback"` (no socket support, ACL-rejected, or connect timed out) run
649
+ * REST polling instead. Never throws — degrades to `{ status: "fallback" }` on
650
+ * a host whose datastore client predates realtime.
651
+ */
652
+ export function useDatastoreSubscription(
653
+ tableId: string | null | undefined,
654
+ handlers?: DatastoreSubscriptionHandlers,
655
+ options?: DatastoreSubscriptionOptions,
656
+ ): { status: DatastoreSubscriptionStatus };
657
+
627
658
  /**
628
659
  * Stateful file-asset resolver hook. Returns `{ url, file, loading, error,
629
660
  * refetch }`. The `url` is an absolute URL composed against the host's API
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ export {
20
20
  useUsers,
21
21
  useGroups,
22
22
  useRecordPermissions,
23
+ useDatastoreSubscription,
23
24
  useWidgetEvent,
24
25
  usePayments,
25
26
  useTheme,
@@ -20,6 +20,7 @@ export {
20
20
  useUsers,
21
21
  useGroups,
22
22
  useRecordPermissions,
23
+ useDatastoreSubscription,
23
24
  useWidgetEvent,
24
25
  usePayments,
25
26
  useTheme,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -35,7 +35,7 @@
35
35
  ],
36
36
  "scripts": {
37
37
  "build": "node scripts/build.js",
38
- "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/linter-users-scope.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js"
38
+ "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js"
39
39
  },
40
40
  "engines": {
41
41
  "node": ">=18"