@colixsystems/datastore-client 0.4.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 +94 -34
- package/dist/client.js +364 -93
- package/dist/client.test.js +298 -63
- package/dist/index.d.ts +133 -78
- package/package.json +2 -2
package/dist/client.js
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
// Datastore client factory.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Datastore client factory (DATA PLANE only).
|
|
2
|
+
//
|
|
3
|
+
// This package is the typed data-plane client: tables (schema), records
|
|
4
|
+
// (CRUD + query + aggregate), and record-level permissions (RLS). It does
|
|
5
|
+
// NOT carry users, groups, files, or payments — those live in sibling
|
|
6
|
+
// packages. The host injects baseUrl, a token provider, a tenant-id
|
|
7
|
+
// provider, an optional per-request header provider (used to attach
|
|
8
|
+
// per-widget scope tokens without this SDK knowing about /widgets/
|
|
9
|
+
// scope-token), and an optional fetch implementation.
|
|
10
|
+
//
|
|
11
|
+
// CRITICAL — snake_case, NO transform. The wire format is snake_case in
|
|
12
|
+
// BOTH directions (REQ-GEN-09) and that is the client contract too. Request
|
|
13
|
+
// bodies are sent snake_case VERBATIM (e.g. { user_id, can_read }). Response
|
|
14
|
+
// objects are returned snake_case VERBATIM (e.g. { id, created_at,
|
|
15
|
+
// table_id }). There is no case-mapping helper anywhere in this package.
|
|
16
|
+
// List endpoints return the { data, meta } envelope verbatim — list methods
|
|
17
|
+
// do not unwrap or remap.
|
|
5
18
|
|
|
6
19
|
import { errorFromResponse, DatastoreError } from "./errors.js";
|
|
7
20
|
import { withRetry } from "./retry.js";
|
|
@@ -12,11 +25,40 @@ function joinUrl(base, path) {
|
|
|
12
25
|
return `${b}${p}`;
|
|
13
26
|
}
|
|
14
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
|
+
|
|
15
57
|
// Serialise a column-filter map into the backend's bracket form
|
|
16
|
-
// (`filter[col]=op:value`). The column names are author-authored and
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
58
|
+
// (`filter[col]=op:value`). The column names are author-authored and pass
|
|
59
|
+
// through verbatim (the backend exempts the `filter` subtree from its
|
|
60
|
+
// snake_case query normalisation); the `op:value` expression is URL-encoded.
|
|
61
|
+
// Used by both the record list and the aggregate query.
|
|
20
62
|
function appendFilter(parts, filter) {
|
|
21
63
|
if (!filter || typeof filter !== "object") return;
|
|
22
64
|
for (const [col, expr] of Object.entries(filter)) {
|
|
@@ -28,8 +70,11 @@ function appendFilter(parts, filter) {
|
|
|
28
70
|
}
|
|
29
71
|
|
|
30
72
|
// Record-list query string. Mirrors the backend contract
|
|
31
|
-
// (`GET /tables/:id/records`): `limit` + `offset` pagination, free-text
|
|
32
|
-
// `
|
|
73
|
+
// (`GET /tables/:id/records`): `limit` + `offset` pagination, free-text `q`,
|
|
74
|
+
// `filter_mode` (and|or), `sort` (e.g. `-created_at`), and
|
|
75
|
+
// `filter[col]=op:value` conditions. Query keys are snake_case on the wire;
|
|
76
|
+
// the caller passes `filterMode` (a JS method-name convenience) which maps to
|
|
77
|
+
// `filter_mode`.
|
|
33
78
|
function buildQueryString(query) {
|
|
34
79
|
if (!query || typeof query !== "object") return "";
|
|
35
80
|
const parts = [];
|
|
@@ -42,13 +87,17 @@ function buildQueryString(query) {
|
|
|
42
87
|
if (query.filterMode === "or" || query.filterMode === "and") {
|
|
43
88
|
parts.push(`filter_mode=${query.filterMode}`);
|
|
44
89
|
}
|
|
90
|
+
if (query.sort != null && query.sort !== "")
|
|
91
|
+
parts.push(`sort=${encodeURIComponent(query.sort)}`);
|
|
45
92
|
appendFilter(parts, query.filter);
|
|
46
93
|
return parts.length ? `?${parts.join("&")}` : "";
|
|
47
94
|
}
|
|
48
95
|
|
|
49
96
|
// Aggregate query string. Mirrors the backend contract
|
|
50
|
-
// (`GET /tables/:id/records/aggregate`): a single `group_by` column, a
|
|
51
|
-
//
|
|
97
|
+
// (`GET /tables/:id/records/aggregate`): a single `group_by` column, a single
|
|
98
|
+
// `sum_field`, and optional `filter[col]=op:value` conditions. The caller
|
|
99
|
+
// passes `groupBy` / `sumField` (JS-name convenience) which map to the
|
|
100
|
+
// snake_case wire params.
|
|
52
101
|
function buildAggregateQueryString(spec) {
|
|
53
102
|
if (!spec || typeof spec !== "object") return "";
|
|
54
103
|
const parts = [];
|
|
@@ -59,54 +108,20 @@ function buildAggregateQueryString(spec) {
|
|
|
59
108
|
return parts.length ? `?${parts.join("&")}` : "";
|
|
60
109
|
}
|
|
61
110
|
|
|
62
|
-
// Record-permission grants (REQ-ACL-06) cross the wire in the project's
|
|
63
|
-
// snake_case contract; this client presents the same camelCase face every
|
|
64
|
-
// other namespace uses (`filterMode`, `groupBy`, `AppUser.groupIds`, …).
|
|
65
|
-
// The field set is small and fixed, so an explicit map stays clearer than a
|
|
66
|
-
// generic deep transform — which this package intentionally avoids.
|
|
67
|
-
function serializePermissionBody(input) {
|
|
68
|
-
if (!input || typeof input !== "object") return {};
|
|
69
|
-
const out = {};
|
|
70
|
-
if (input.userId !== undefined) out.user_id = input.userId;
|
|
71
|
-
if (input.groupId !== undefined) out.group_id = input.groupId;
|
|
72
|
-
if (input.canRead !== undefined) out.can_read = input.canRead;
|
|
73
|
-
if (input.canWrite !== undefined) out.can_write = input.canWrite;
|
|
74
|
-
if (input.canDelete !== undefined) out.can_delete = input.canDelete;
|
|
75
|
-
if (input.canGrant !== undefined) out.can_grant = input.canGrant;
|
|
76
|
-
return out;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function deserializePermission(row) {
|
|
80
|
-
if (!row || typeof row !== "object") return row;
|
|
81
|
-
return {
|
|
82
|
-
id: row.id,
|
|
83
|
-
tableId: row.table_id,
|
|
84
|
-
recordId: row.record_id ?? null,
|
|
85
|
-
userId: row.user_id ?? null,
|
|
86
|
-
groupId: row.group_id ?? null,
|
|
87
|
-
canRead: !!row.can_read,
|
|
88
|
-
canWrite: !!row.can_write,
|
|
89
|
-
canDelete: !!row.can_delete,
|
|
90
|
-
canGrant: !!row.can_grant,
|
|
91
|
-
sourceKind: row.source_kind ?? null,
|
|
92
|
-
sourceId: row.source_id ?? null,
|
|
93
|
-
createdAt: row.created_at,
|
|
94
|
-
updatedAt: row.updated_at,
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
111
|
/**
|
|
99
112
|
* @param {object} opts
|
|
100
113
|
* @param {string} opts.baseUrl
|
|
101
|
-
* @param {() => string | Promise<string>} opts.getToken Returns the Authorization header value (e.g. "Bearer <jwt>").
|
|
114
|
+
* @param {() => string | Promise<string>} opts.getToken Returns the Authorization header value (e.g. "Bearer <jwt>"); the "Bearer " prefix is added if missing.
|
|
102
115
|
* @param {() => string | Promise<string>} opts.getTenantId Returns the x-tenant-id header value.
|
|
116
|
+
* @param {(ctx: { namespace: string, operation: string }) => object | Promise<object>} [opts.getRequestHeaders] Optional per-request extra headers (e.g. { 'X-Widget-Scopes': '...' }).
|
|
103
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.
|
|
104
119
|
*/
|
|
105
120
|
export function createDatastoreClient(opts) {
|
|
106
121
|
if (!opts || typeof opts !== "object") {
|
|
107
122
|
throw new TypeError("createDatastoreClient: opts is required");
|
|
108
123
|
}
|
|
109
|
-
const { baseUrl, getToken, getTenantId } = opts;
|
|
124
|
+
const { baseUrl, getToken, getTenantId, getRequestHeaders } = opts;
|
|
110
125
|
if (typeof baseUrl !== "string" || baseUrl.length === 0) {
|
|
111
126
|
throw new TypeError("createDatastoreClient: baseUrl is required");
|
|
112
127
|
}
|
|
@@ -118,14 +133,27 @@ export function createDatastoreClient(opts) {
|
|
|
118
133
|
"createDatastoreClient: getTenantId must be a function",
|
|
119
134
|
);
|
|
120
135
|
}
|
|
136
|
+
if (getRequestHeaders !== undefined && typeof getRequestHeaders !== "function") {
|
|
137
|
+
throw new TypeError(
|
|
138
|
+
"createDatastoreClient: getRequestHeaders must be a function when provided",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
121
141
|
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
122
142
|
if (typeof fetchImpl !== "function") {
|
|
123
143
|
throw new TypeError(
|
|
124
144
|
"createDatastoreClient: no fetch available. Pass fetchImpl or run in an environment with global fetch.",
|
|
125
145
|
);
|
|
126
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);
|
|
127
155
|
|
|
128
|
-
async function request(method, path, { body, timeoutMs } = {}) {
|
|
156
|
+
async function request(method, path, { body, timeoutMs, namespace, operation } = {}) {
|
|
129
157
|
const url = joinUrl(baseUrl, path);
|
|
130
158
|
const token = await getToken();
|
|
131
159
|
const tenantId = await getTenantId();
|
|
@@ -138,6 +166,15 @@ export function createDatastoreClient(opts) {
|
|
|
138
166
|
if (tenantId) headers["x-tenant-id"] = tenantId;
|
|
139
167
|
if (body !== undefined) headers["content-type"] = "application/json";
|
|
140
168
|
|
|
169
|
+
if (getRequestHeaders) {
|
|
170
|
+
const extra = await getRequestHeaders({ namespace, operation });
|
|
171
|
+
if (extra && typeof extra === "object") {
|
|
172
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
173
|
+
if (v !== undefined && v !== null) headers[k] = v;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
141
178
|
return withRetry({ method, timeoutMs }, async (signal) => {
|
|
142
179
|
let res;
|
|
143
180
|
try {
|
|
@@ -171,34 +208,267 @@ export function createDatastoreClient(opts) {
|
|
|
171
208
|
});
|
|
172
209
|
}
|
|
173
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
|
+
|
|
174
409
|
function recordsNs(table) {
|
|
175
410
|
if (typeof table !== "string" || table.length === 0) {
|
|
176
411
|
throw new TypeError("records(table): table must be a non-empty string");
|
|
177
412
|
}
|
|
178
413
|
const enc = encodeURIComponent(table);
|
|
179
414
|
return {
|
|
415
|
+
// GET /tables/{t}/records — returns the { data, meta } envelope verbatim.
|
|
180
416
|
list: (query) =>
|
|
181
|
-
request("GET", `/tables/${enc}/records${buildQueryString(query)}
|
|
417
|
+
request("GET", `/tables/${enc}/records${buildQueryString(query)}`, {
|
|
418
|
+
namespace: "records",
|
|
419
|
+
operation: "list",
|
|
420
|
+
}),
|
|
421
|
+
// GET /tables/{t}/records/{id}
|
|
182
422
|
get: (id) =>
|
|
183
|
-
request("GET", `/tables/${enc}/records/${encodeURIComponent(id)}
|
|
423
|
+
request("GET", `/tables/${enc}/records/${encodeURIComponent(id)}`, {
|
|
424
|
+
namespace: "records",
|
|
425
|
+
operation: "get",
|
|
426
|
+
}),
|
|
427
|
+
// POST /tables/{t}/records — values sent snake_case verbatim.
|
|
184
428
|
create: (values) =>
|
|
185
|
-
request("POST", `/tables/${enc}/records`, {
|
|
429
|
+
request("POST", `/tables/${enc}/records`, {
|
|
430
|
+
body: values,
|
|
431
|
+
namespace: "records",
|
|
432
|
+
operation: "create",
|
|
433
|
+
}),
|
|
434
|
+
// PATCH /tables/{t}/records/{id} — partial update (PATCH, not PUT).
|
|
186
435
|
update: (id, values) =>
|
|
187
436
|
request("PATCH", `/tables/${enc}/records/${encodeURIComponent(id)}`, {
|
|
188
437
|
body: values,
|
|
438
|
+
namespace: "records",
|
|
439
|
+
operation: "update",
|
|
189
440
|
}),
|
|
441
|
+
// DELETE /tables/{t}/records/{id}
|
|
190
442
|
delete: (id) =>
|
|
191
|
-
request("DELETE", `/tables/${enc}/records/${encodeURIComponent(id)}
|
|
443
|
+
request("DELETE", `/tables/${enc}/records/${encodeURIComponent(id)}`, {
|
|
444
|
+
namespace: "records",
|
|
445
|
+
operation: "delete",
|
|
446
|
+
}),
|
|
447
|
+
// GET /tables/{t}/records/aggregate — returns [{ group, count, sum }].
|
|
192
448
|
aggregate: (spec) =>
|
|
193
449
|
request(
|
|
194
450
|
"GET",
|
|
195
451
|
`/tables/${enc}/records/aggregate${buildAggregateQueryString(spec)}`,
|
|
452
|
+
{ namespace: "records", operation: "aggregate" },
|
|
196
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),
|
|
197
466
|
// REQ-ACL-06: row-level permission grants on a single record. A grant
|
|
198
|
-
// to a user OR group with `
|
|
199
|
-
// Chat widget's channel model.
|
|
467
|
+
// to a user OR group with `can_read` is what membership means — see the
|
|
468
|
+
// Chat widget's channel model. Bodies are sent snake_case verbatim and
|
|
469
|
+
// rows are returned snake_case verbatim. Mirrors the backend routes:
|
|
200
470
|
// GET …/permissions list grants ({ data, meta })
|
|
201
|
-
// POST …/permissions grant one (provide
|
|
471
|
+
// POST …/permissions grant one (provide user_id XOR group_id)
|
|
202
472
|
// PUT …/permissions/{permissionId} update one grant's flags
|
|
203
473
|
// DELETE …/permissions/{permissionId} revoke one grant
|
|
204
474
|
permissions: (recordId) => {
|
|
@@ -209,48 +479,49 @@ export function createDatastoreClient(opts) {
|
|
|
209
479
|
}
|
|
210
480
|
const base = `/tables/${enc}/records/${encodeURIComponent(recordId)}/permissions`;
|
|
211
481
|
return {
|
|
212
|
-
list:
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
grant:
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
),
|
|
223
|
-
update:
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
),
|
|
230
|
-
),
|
|
482
|
+
list: () =>
|
|
483
|
+
request("GET", base, {
|
|
484
|
+
namespace: "permissions",
|
|
485
|
+
operation: "list",
|
|
486
|
+
}),
|
|
487
|
+
grant: (body) =>
|
|
488
|
+
request("POST", base, {
|
|
489
|
+
body,
|
|
490
|
+
namespace: "permissions",
|
|
491
|
+
operation: "grant",
|
|
492
|
+
}),
|
|
493
|
+
update: (permissionId, patch) =>
|
|
494
|
+
request("PUT", `${base}/${encodeURIComponent(permissionId)}`, {
|
|
495
|
+
body: patch,
|
|
496
|
+
namespace: "permissions",
|
|
497
|
+
operation: "update",
|
|
498
|
+
}),
|
|
231
499
|
revoke: (permissionId) =>
|
|
232
|
-
request("DELETE", `${base}/${encodeURIComponent(permissionId)}
|
|
500
|
+
request("DELETE", `${base}/${encodeURIComponent(permissionId)}`, {
|
|
501
|
+
namespace: "permissions",
|
|
502
|
+
operation: "revoke",
|
|
503
|
+
}),
|
|
233
504
|
};
|
|
234
505
|
},
|
|
235
506
|
};
|
|
236
507
|
}
|
|
237
508
|
|
|
509
|
+
const tables = {
|
|
510
|
+
// GET /tables — returns the { data, meta } envelope verbatim.
|
|
511
|
+
list: () => request("GET", `/tables`, { namespace: "tables", operation: "list" }),
|
|
512
|
+
// GET /tables/{id} — full table schema { id, name, columns, ... }.
|
|
513
|
+
get: (idOrName) =>
|
|
514
|
+
request("GET", `/tables/${encodeURIComponent(idOrName)}`, {
|
|
515
|
+
namespace: "tables",
|
|
516
|
+
operation: "get",
|
|
517
|
+
}),
|
|
518
|
+
};
|
|
519
|
+
|
|
238
520
|
return {
|
|
239
|
-
tables
|
|
240
|
-
list: () => request("GET", `/tables`),
|
|
241
|
-
get: (idOrName) =>
|
|
242
|
-
request("GET", `/tables/${encodeURIComponent(idOrName)}`),
|
|
243
|
-
},
|
|
521
|
+
tables,
|
|
244
522
|
records: recordsNs,
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
me: () => request("GET", `/auth/app/me`),
|
|
249
|
-
get: (id) => request("GET", `/app/users/${encodeURIComponent(id)}`),
|
|
250
|
-
},
|
|
251
|
-
groups: {
|
|
252
|
-
// The groups the calling principal belongs to.
|
|
253
|
-
listMine: () => request("GET", `/app/groups/mine`),
|
|
254
|
-
},
|
|
523
|
+
// schema(tableId) is an alias of tables.get for the table's column
|
|
524
|
+
// structure — the host calls ctx.datastore.schema(t).
|
|
525
|
+
schema: (tableId) => tables.get(tableId),
|
|
255
526
|
};
|
|
256
527
|
}
|