@colixsystems/datastore-client 0.5.0 → 0.6.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
@@ -5,6 +5,7 @@ Typed, scoped **data-plane** client for the [AppStudio](https://github.com/appst
5
5
  - **tables** — table schema (id, name, columns) via `tables.{list,get}` and the `schema(tableId)` alias.
6
6
  - **records** — record CRUD, querying, and aggregation via `records(tableId).{list,get,create,update,delete,aggregate}`.
7
7
  - **record-level permissions** — RLS grants on a single record (REQ-ACL-06) via `records(tableId).permissions(recordId).{list,grant,update,revoke}`.
8
+ - **realtime** — subscribe to a table's live change stream (REQ-RT-07) via `records(tableId).subscribe({ onCreated, onUpdated, onDeleted, onStatus })`, returning an unsubscribe function.
8
9
 
9
10
  It does **not** cover users, groups, files, or payments — those live in sibling packages ([`files-client`](../files-client), [`directory-client`](../directory-client), [`payments-client`](../payments-client)). This is a standalone `fetch`-based client you instantiate yourself with `createDatastoreClient({ baseUrl, getToken, getTenantId })`.
10
11
 
@@ -14,7 +15,9 @@ It does **not** cover users, groups, files, or payments — those live in siblin
14
15
 
15
16
  ## Status
16
17
 
17
- `v0.5.0` — pre-publish. Not yet published to npm.
18
+ `v0.6.0` — pre-publish. Not yet published to npm.
19
+
20
+ > **0.6.0 (additive):** added `records(tableId).subscribe({ onCreated, onUpdated, onDeleted, onStatus }, { fallbackAfterMs? })` (REQ-RT-07) — a WebSocket subscription to the table's realtime change stream at `<baseUrl>/datastore/ws`, returning a synchronous unsubscribe function. Server-gated by the same read ACL as REST. New optional `webSocketImpl` factory option (defaults to `globalThis.WebSocket`, present in browsers and React Native); when no impl is available `onStatus` reports `"fallback"` so callers poll. No existing method changed.
18
21
 
19
22
  > **0.5.0 (breaking):** the client is now **snake_case end to end with NO transform** (REQ-GEN-09) and is scoped to the **data plane only**.
20
23
  > - The old camelCase↔snake_case permission mappers are **deleted**. Permission bodies are sent snake_case verbatim (`{ user_id, can_read, … }`) and rows are returned snake_case verbatim (`{ id, table_id, can_read, … }`).
@@ -101,6 +104,7 @@ await perms.revoke(grant.id);
101
104
  | `records(t).permissions(r).grant(body)` | `POST /tables/{t}/records/{r}/permissions` | `RecordPermission` |
102
105
  | `records(t).permissions(r).update(pid, patch)` | `PUT /tables/{t}/records/{r}/permissions/{pid}` | `RecordPermission` |
103
106
  | `records(t).permissions(r).revoke(pid)` | `DELETE /tables/{t}/records/{r}/permissions/{pid}` | `void` |
107
+ | `records(t).subscribe(handlers, opts?)` | `WS <baseUrl>/datastore/ws` (`{type:"subscribe",tableId}`) | `() => void` (unsubscribe) |
104
108
 
105
109
  ### Query params (snake_case on the wire)
106
110
 
@@ -124,6 +128,7 @@ await perms.revoke(grant.id);
124
128
  | `getTenantId` | `() => string \| Promise<string>` | Required. Returns the `x-tenant-id` value. |
125
129
  | `getRequestHeaders` | `({ namespace, operation }) => object \| Promise<object>` | Optional. Extra headers merged per request (e.g. scope tokens). `namespace` is one of `tables` / `records` / `permissions`. |
126
130
  | `fetchImpl` | `typeof fetch` | Optional. Defaults to `globalThis.fetch`. |
131
+ | `webSocketImpl` | `typeof WebSocket` | Optional. Used by `records(t).subscribe(...)`. Defaults to `globalThis.WebSocket` (browser + React Native). When absent, `subscribe` reports `"fallback"` and opens no socket. |
127
132
 
128
133
  ## Transport
129
134
 
package/dist/client.js CHANGED
@@ -25,6 +25,35 @@ function joinUrl(base, path) {
25
25
  return `${b}${p}`;
26
26
  }
27
27
 
28
+ // REQ-RT-07: derive the realtime WebSocket URL from the REST baseUrl. The
29
+ // datastore broadcast plane lives at `<api-base>/datastore/ws` (the same
30
+ // `/api/v1` mount, so we append `/datastore/ws` to the base path). The
31
+ // baseUrl may be absolute ("https://host/api/v1", the native export) or a
32
+ // same-origin relative path ("/api/v1", the web build) — the latter is
33
+ // resolved against the page origin via globalThis.location. Returns null
34
+ // when no usable URL can be formed (a relative base with no page origin,
35
+ // e.g. SSR), so the caller can degrade to its polling fallback instead of
36
+ // throwing. Pure string parsing (no `new URL`) so it works identically in
37
+ // the browser and under React Native, whose URL support is incomplete.
38
+ // http→ws, https→wss.
39
+ function wsUrlFromBase(baseUrl) {
40
+ let base = baseUrl;
41
+ if (base.startsWith("/")) {
42
+ const origin =
43
+ typeof globalThis !== "undefined" &&
44
+ globalThis.location &&
45
+ globalThis.location.origin;
46
+ if (!origin) return null;
47
+ base = origin + base;
48
+ }
49
+ const m = base.match(/^(https?):\/\/([^/]+)(\/.*)?$/i);
50
+ if (!m) return null;
51
+ const wsProto = m[1].toLowerCase() === "https" ? "wss" : "ws";
52
+ const host = m[2];
53
+ const path = (m[3] || "").replace(/\/$/, "");
54
+ return `${wsProto}://${host}${path}/datastore/ws`;
55
+ }
56
+
28
57
  // Serialise a column-filter map into the backend's bracket form
29
58
  // (`filter[col]=op:value`). The column names are author-authored and pass
30
59
  // through verbatim (the backend exempts the `filter` subtree from its
@@ -86,6 +115,7 @@ function buildAggregateQueryString(spec) {
86
115
  * @param {() => string | Promise<string>} opts.getTenantId Returns the x-tenant-id header value.
87
116
  * @param {(ctx: { namespace: string, operation: string }) => object | Promise<object>} [opts.getRequestHeaders] Optional per-request extra headers (e.g. { 'X-Widget-Scopes': '...' }).
88
117
  * @param {typeof fetch} [opts.fetchImpl] Defaults to globalThis.fetch.
118
+ * @param {typeof WebSocket} [opts.webSocketImpl] WebSocket constructor for records(t).subscribe(). Defaults to globalThis.WebSocket (present in browsers AND React Native). When absent, subscribe() reports "fallback" so callers poll.
89
119
  */
90
120
  export function createDatastoreClient(opts) {
91
121
  if (!opts || typeof opts !== "object") {
@@ -114,6 +144,14 @@ export function createDatastoreClient(opts) {
114
144
  "createDatastoreClient: no fetch available. Pass fetchImpl or run in an environment with global fetch.",
115
145
  );
116
146
  }
147
+ // REQ-RT-07: WebSocket impl for records(t).subscribe(). Defaults to the
148
+ // platform global (browser AND React Native both provide `WebSocket`), so
149
+ // the same client streams realtime on web and in the Expo export with no
150
+ // host change. Overridable for tests / unusual runtimes. When absent,
151
+ // subscribe() degrades to an immediate "fallback" status (callers poll).
152
+ const WebSocketImpl =
153
+ opts.webSocketImpl ??
154
+ (typeof globalThis !== "undefined" ? globalThis.WebSocket : undefined);
117
155
 
118
156
  async function request(method, path, { body, timeoutMs, namespace, operation } = {}) {
119
157
  const url = joinUrl(baseUrl, path);
@@ -170,6 +208,204 @@ export function createDatastoreClient(opts) {
170
208
  });
171
209
  }
172
210
 
211
+ // REQ-RT-07: realtime subscription lifecycle for records(t).subscribe().
212
+ // One implementation shared by web and the native export (both pass the
213
+ // platform-global WebSocket). connect() resolves the token + tenant per
214
+ // attempt — so a refreshed token rides the next reconnect — opens the
215
+ // socket, sends { type: "subscribe", tableId }, and dispatches record.*
216
+ // envelopes to the handlers. Backoff is capped exponential; a "fallback"
217
+ // status fires if the first subscribe doesn't land within fallbackAfterMs
218
+ // or the socket drops, so the caller can switch to REST polling.
219
+ function subscribeRecords(table, handlers, options) {
220
+ const h = handlers && typeof handlers === "object" ? handlers : {};
221
+ const fallbackAfterMs =
222
+ options && Number.isFinite(options.fallbackAfterMs)
223
+ ? options.fallbackAfterMs
224
+ : 3000;
225
+
226
+ let socket = null;
227
+ let stopped = false;
228
+ let attempt = 0;
229
+ let reconnectTimer = null;
230
+ let fallbackTimer = null;
231
+ let didEmitFallback = false;
232
+ let status = "connecting";
233
+
234
+ const emitStatus = (next) => {
235
+ if (status === next) return;
236
+ status = next;
237
+ if (typeof h.onStatus === "function") {
238
+ try {
239
+ h.onStatus(next);
240
+ } catch {
241
+ /* ignore subscriber error */
242
+ }
243
+ }
244
+ };
245
+ const fire = (fn, record) => {
246
+ if (typeof fn === "function") {
247
+ try {
248
+ fn(record);
249
+ } catch {
250
+ /* ignore subscriber error */
251
+ }
252
+ }
253
+ };
254
+ const clearFallback = () => {
255
+ if (fallbackTimer) {
256
+ clearTimeout(fallbackTimer);
257
+ fallbackTimer = null;
258
+ }
259
+ };
260
+ const scheduleFallback = () => {
261
+ if (didEmitFallback) return;
262
+ clearFallback();
263
+ fallbackTimer = setTimeout(() => {
264
+ if (stopped) return;
265
+ didEmitFallback = true;
266
+ emitStatus("fallback");
267
+ }, fallbackAfterMs);
268
+ };
269
+ const scheduleReconnect = () => {
270
+ if (stopped) return;
271
+ if (reconnectTimer) clearTimeout(reconnectTimer);
272
+ const delay = Math.min(1000 * 2 ** attempt, 16000);
273
+ attempt += 1;
274
+ emitStatus(didEmitFallback ? "fallback" : "reconnecting");
275
+ reconnectTimer = setTimeout(() => {
276
+ void connect();
277
+ }, delay);
278
+ };
279
+
280
+ async function connect() {
281
+ if (stopped) return;
282
+ if (typeof WebSocketImpl !== "function") {
283
+ didEmitFallback = true;
284
+ emitStatus("fallback");
285
+ return;
286
+ }
287
+ const wsBase = wsUrlFromBase(baseUrl);
288
+ if (!wsBase) {
289
+ didEmitFallback = true;
290
+ emitStatus("fallback");
291
+ return;
292
+ }
293
+
294
+ let token = "";
295
+ let tenantId = "";
296
+ try {
297
+ token = (await getToken()) || "";
298
+ tenantId = (await getTenantId()) || "";
299
+ } catch {
300
+ /* anonymous attempt — leave empty */
301
+ }
302
+ if (stopped) return;
303
+
304
+ const params = new URLSearchParams();
305
+ const rawToken = token.startsWith("Bearer ") ? token.slice(7) : token;
306
+ // Server reads the token for claims; an anonymous subscribe needs the
307
+ // tenantId for routing. Token path wins, mirroring the REST headers.
308
+ if (rawToken) params.set("token", rawToken);
309
+ else if (tenantId) params.set("tenantId", tenantId);
310
+ const qs = params.toString();
311
+ const url = qs ? `${wsBase}?${qs}` : wsBase;
312
+
313
+ let ws;
314
+ try {
315
+ ws = new WebSocketImpl(url);
316
+ } catch {
317
+ scheduleReconnect();
318
+ scheduleFallback();
319
+ return;
320
+ }
321
+ socket = ws;
322
+ scheduleFallback();
323
+ emitStatus(status === "live" ? "reconnecting" : "connecting");
324
+
325
+ const send = (obj) => {
326
+ try {
327
+ ws.send(JSON.stringify(obj));
328
+ } catch {
329
+ /* socket closing — the down handler will reconnect */
330
+ }
331
+ };
332
+
333
+ // `on*` property assignment (not addEventListener) for the widest
334
+ // cross-platform support — both the browser and React Native guarantee
335
+ // these handler properties on WebSocket.
336
+ ws.onopen = () => send({ type: "subscribe", tableId: table });
337
+ ws.onmessage = (ev) => {
338
+ let env;
339
+ try {
340
+ env = JSON.parse(ev.data);
341
+ } catch {
342
+ return;
343
+ }
344
+ if (!env || typeof env !== "object") return;
345
+ switch (env.type) {
346
+ case "ping":
347
+ send({ type: "pong" });
348
+ return;
349
+ case "subscribed":
350
+ attempt = 0;
351
+ clearFallback();
352
+ didEmitFallback = false;
353
+ emitStatus("live");
354
+ return;
355
+ case "record.created":
356
+ fire(h.onCreated, env.record);
357
+ return;
358
+ case "record.updated":
359
+ fire(h.onUpdated, env.record);
360
+ return;
361
+ case "record.deleted":
362
+ fire(h.onDeleted, env.record);
363
+ return;
364
+ case "error":
365
+ // FORBIDDEN / NOT_FOUND on subscribe → reconnecting won't help.
366
+ if (env.code === "FORBIDDEN" || env.code === "NOT_FOUND") {
367
+ didEmitFallback = true;
368
+ emitStatus("fallback");
369
+ try {
370
+ ws.close(4000, "subscribe-rejected");
371
+ } catch {
372
+ /* ignore */
373
+ }
374
+ }
375
+ return;
376
+ default:
377
+ return;
378
+ }
379
+ };
380
+ const onDown = () => {
381
+ if (socket === ws) socket = null;
382
+ if (stopped) return;
383
+ scheduleReconnect();
384
+ };
385
+ ws.onclose = onDown;
386
+ ws.onerror = onDown;
387
+ }
388
+
389
+ void connect();
390
+
391
+ return function unsubscribe() {
392
+ stopped = true;
393
+ if (reconnectTimer) {
394
+ clearTimeout(reconnectTimer);
395
+ reconnectTimer = null;
396
+ }
397
+ clearFallback();
398
+ if (socket) {
399
+ try {
400
+ socket.close(1000, "unsubscribe");
401
+ } catch {
402
+ /* ignore */
403
+ }
404
+ socket = null;
405
+ }
406
+ };
407
+ }
408
+
173
409
  function recordsNs(table) {
174
410
  if (typeof table !== "string" || table.length === 0) {
175
411
  throw new TypeError("records(table): table must be a non-empty string");
@@ -215,6 +451,18 @@ export function createDatastoreClient(opts) {
215
451
  `/tables/${enc}/records/aggregate${buildAggregateQueryString(spec)}`,
216
452
  { namespace: "records", operation: "aggregate" },
217
453
  ),
454
+ // REQ-RT-07: subscribe to this table's realtime change stream over the
455
+ // `/datastore/ws` broadcast plane. Returns a synchronous unsubscribe
456
+ // function. `handlers`: { onCreated, onUpdated, onDeleted, onStatus }.
457
+ // `onStatus(state)` reports the transport: "connecting" | "live" |
458
+ // "reconnecting" | "fallback". The server gates each subscribe by the
459
+ // same read ACL REST honours; on a FORBIDDEN/NOT_FOUND reject (or when
460
+ // no WebSocket impl is available) the helper emits "fallback" so the
461
+ // caller can poll instead. record envelopes carry the snake_case row
462
+ // verbatim, exactly like the REST responses. Reconnect uses capped
463
+ // exponential backoff; a ping/pong heartbeat keeps the socket alive.
464
+ subscribe: (handlers, options) =>
465
+ subscribeRecords(table, handlers, options),
218
466
  // REQ-ACL-06: row-level permission grants on a single record. A grant
219
467
  // to a user OR group with `can_read` is what membership means — see the
220
468
  // Chat widget's channel model. Bodies are sent snake_case verbatim and
@@ -318,3 +318,133 @@ test("non-OK responses throw a typed error", async () => {
318
318
  return true;
319
319
  });
320
320
  });
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // REQ-RT-07 — records(t).subscribe() realtime transport.
324
+ // ---------------------------------------------------------------------------
325
+
326
+ // Minimal WebSocket double: records the URL, exposes the on* handlers the
327
+ // client assigns, and lets the test drive open/message events + inspect sends.
328
+ class MockWS {
329
+ constructor(url) {
330
+ this.url = url;
331
+ this.sent = [];
332
+ this.closed = false;
333
+ MockWS.last = this;
334
+ }
335
+ send(data) {
336
+ this.sent.push(JSON.parse(data));
337
+ }
338
+ close() {
339
+ this.closed = true;
340
+ if (this.onclose) this.onclose();
341
+ }
342
+ emitOpen() {
343
+ if (this.onopen) this.onopen();
344
+ }
345
+ emitMessage(obj) {
346
+ if (this.onmessage) this.onmessage({ data: JSON.stringify(obj) });
347
+ }
348
+ }
349
+
350
+ // connect() awaits getToken + getTenantId, so the socket is created on a
351
+ // later microtask/timer turn — flush with a macrotask tick.
352
+ const tick = () => new Promise((r) => setTimeout(r, 0));
353
+
354
+ function subClient(extra = {}) {
355
+ return createDatastoreClient({
356
+ baseUrl: "https://api.example.com/api/v1",
357
+ getToken: () => "Bearer as_testkey",
358
+ getTenantId: () => "tenant-1",
359
+ fetchImpl: async () => ({ ok: true, status: 200, text: async () => "{}" }),
360
+ webSocketImpl: MockWS,
361
+ ...extra,
362
+ });
363
+ }
364
+
365
+ test("subscribe: derives wss URL, sends subscribe on open, dispatches record.* + ping", async () => {
366
+ MockWS.last = null;
367
+ const events = [];
368
+ const statuses = [];
369
+ const stop = subClient().records("t1").subscribe({
370
+ onCreated: (r) => events.push(["created", r]),
371
+ onUpdated: (r) => events.push(["updated", r]),
372
+ onDeleted: (r) => events.push(["deleted", r]),
373
+ onStatus: (s) => statuses.push(s),
374
+ });
375
+ await tick();
376
+
377
+ const ws = MockWS.last;
378
+ assert.ok(ws, "socket constructed");
379
+ // wss (from https), the /api/v1 mount + /datastore/ws, raw token (Bearer stripped).
380
+ assert.match(ws.url, /^wss:\/\/api\.example\.com\/api\/v1\/datastore\/ws\?/);
381
+ assert.match(ws.url, /token=as_testkey/);
382
+ assert.ok(!ws.url.includes("Bearer"), "Bearer prefix stripped from query token");
383
+
384
+ ws.emitOpen();
385
+ assert.deepEqual(ws.sent[0], { type: "subscribe", tableId: "t1" });
386
+
387
+ ws.emitMessage({ type: "subscribed" });
388
+ assert.ok(statuses.includes("live"), "subscribed → live");
389
+
390
+ ws.emitMessage({ type: "record.created", record: { id: "r1" } });
391
+ ws.emitMessage({ type: "record.updated", record: { id: "r1", x: 2 } });
392
+ ws.emitMessage({ type: "record.deleted", record: { id: "r1" } });
393
+ assert.deepEqual(events, [
394
+ ["created", { id: "r1" }],
395
+ ["updated", { id: "r1", x: 2 }],
396
+ ["deleted", { id: "r1" }],
397
+ ]);
398
+
399
+ ws.emitMessage({ type: "ping" });
400
+ assert.deepEqual(ws.sent[ws.sent.length - 1], { type: "pong" });
401
+
402
+ stop();
403
+ assert.equal(ws.closed, true, "unsubscribe closes the socket");
404
+ });
405
+
406
+ test("subscribe: anonymous (no token) routes via tenantId query", async () => {
407
+ MockWS.last = null;
408
+ const stop = subClient({ getToken: () => "" })
409
+ .records("t1")
410
+ .subscribe({ onStatus: () => {} });
411
+ await tick();
412
+ assert.match(MockWS.last.url, /tenantId=tenant-1/);
413
+ assert.ok(!MockWS.last.url.includes("token="), "no token param when anonymous");
414
+ stop();
415
+ });
416
+
417
+ test("subscribe: FORBIDDEN reject → fallback + socket closed", async () => {
418
+ MockWS.last = null;
419
+ const statuses = [];
420
+ const stop = subClient()
421
+ .records("t1")
422
+ .subscribe({ onStatus: (s) => statuses.push(s) });
423
+ await tick();
424
+ MockWS.last.emitMessage({ type: "error", code: "FORBIDDEN" });
425
+ assert.ok(statuses.includes("fallback"), "ACL reject → fallback");
426
+ assert.equal(MockWS.last.closed, true);
427
+ stop();
428
+ });
429
+
430
+ test("subscribe: emits fallback when the first subscribe never lands", async () => {
431
+ MockWS.last = null;
432
+ const statuses = [];
433
+ const stop = subClient()
434
+ .records("t1")
435
+ .subscribe({ onStatus: (s) => statuses.push(s) }, { fallbackAfterMs: 5 });
436
+ await new Promise((r) => setTimeout(r, 30)); // never emit "subscribed"
437
+ assert.ok(statuses.includes("fallback"), "timeout → fallback");
438
+ stop();
439
+ });
440
+
441
+ test("subscribe: no WebSocket impl → immediate fallback", async () => {
442
+ const statuses = [];
443
+ // A non-function impl trips the same guard as a missing global.
444
+ const stop = subClient({ webSocketImpl: 0 })
445
+ .records("t1")
446
+ .subscribe({ onStatus: (s) => statuses.push(s) });
447
+ await tick();
448
+ assert.deepEqual(statuses, ["fallback"]);
449
+ stop();
450
+ });
package/dist/index.d.ts CHANGED
@@ -151,6 +151,29 @@ export interface RecordPermissionsNamespace {
151
151
  revoke(permissionId: string): Promise<void>;
152
152
  }
153
153
 
154
+ // REQ-RT-07 realtime subscription transport state, reported via
155
+ // `subscribe({ onStatus })`. "live" = socket open + subscribe acked;
156
+ // "fallback" = the caller should poll (no WebSocket impl, subscribe rejected
157
+ // by ACL, or the first connect timed out).
158
+ export type SubscriptionStatus =
159
+ | "connecting"
160
+ | "live"
161
+ | "reconnecting"
162
+ | "fallback";
163
+
164
+ export interface SubscriptionHandlers {
165
+ onCreated?: (record: Record_) => void;
166
+ onUpdated?: (record: Record_) => void;
167
+ onDeleted?: (record: Record_) => void;
168
+ onStatus?: (status: SubscriptionStatus) => void;
169
+ }
170
+
171
+ export interface SubscriptionOptions {
172
+ // Emit "fallback" if the first subscribe doesn't land within this many ms
173
+ // (default 3000) so the caller can start polling without waiting forever.
174
+ fallbackAfterMs?: number;
175
+ }
176
+
154
177
  export interface RecordsNamespace {
155
178
  list(query?: Query): Promise<Page<Record_>>;
156
179
  get(id: string): Promise<Record_>;
@@ -159,6 +182,12 @@ export interface RecordsNamespace {
159
182
  delete(id: string): Promise<void>;
160
183
  aggregate(spec: AggregateSpec): Promise<AggregateResult>;
161
184
  permissions(recordId: string): RecordPermissionsNamespace;
185
+ // REQ-RT-07: subscribe to this table's realtime change stream. Returns a
186
+ // synchronous unsubscribe function.
187
+ subscribe(
188
+ handlers: SubscriptionHandlers,
189
+ options?: SubscriptionOptions,
190
+ ): () => void;
162
191
  }
163
192
 
164
193
  export interface DatastoreClient {
@@ -185,6 +214,10 @@ export interface CreateDatastoreClientOptions {
185
214
  ctx: RequestHeadersContext,
186
215
  ) => Record<string, string> | Promise<Record<string, string>>;
187
216
  fetchImpl?: typeof fetch;
217
+ // REQ-RT-07: WebSocket constructor for records(t).subscribe(). Defaults to
218
+ // globalThis.WebSocket (browser + React Native). Omit in environments
219
+ // without one — subscribe() then reports "fallback".
220
+ webSocketImpl?: typeof WebSocket;
188
221
  }
189
222
 
190
223
  export function createDatastoreClient(
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@colixsystems/datastore-client",
3
- "version": "0.5.0",
4
- "description": "Typed, scoped data-plane client for the AppStudio datastore API (tables, records, aggregates, record-level permissions). snake_case wire contract, no transform.",
3
+ "version": "0.6.0",
4
+ "description": "Typed, scoped data-plane client for the AppStudio datastore API (tables, records, aggregates, record-level permissions, realtime subscribe). snake_case wire contract, no transform.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",