@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 +6 -1
- package/dist/client.js +248 -0
- package/dist/client.test.js +130 -0
- package/dist/index.d.ts +33 -0
- package/package.json +2 -2
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.
|
|
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
|
package/dist/client.test.js
CHANGED
|
@@ -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.
|
|
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",
|