@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/dist/client.js CHANGED
@@ -1,7 +1,20 @@
1
- // Datastore client factory.
2
- // The host injects baseUrl, a token provider, a tenant-id provider, and an
3
- // optional fetch implementation. Widgets never touch this directly they
4
- // receive a built client through WidgetContext.datastore.
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
- // pass through verbatim (the backend exempts the `filter` subtree from
18
- // its snake_case query normalisation); the `op:value` expression is URL-
19
- // encoded. Used by both the record list and the aggregate query.
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
- // `q`, `filter_mode` (and|or), and `filter[col]=op:value` conditions.
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
- // single `sum_field`, and optional `filter[col]=op:value` conditions.
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`, { body: values }),
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 `canRead` is what membership means — see the
199
- // Chat widget's channel model. Mirrors the backend routes:
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 userId XOR groupId)
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: async () => {
213
- const res = await request("GET", base);
214
- const rows = Array.isArray(res?.data) ? res.data : [];
215
- return { data: rows.map(deserializePermission), meta: res?.meta };
216
- },
217
- grant: async (grant) =>
218
- deserializePermission(
219
- await request("POST", base, {
220
- body: serializePermissionBody(grant),
221
- }),
222
- ),
223
- update: async (permissionId, patch) =>
224
- deserializePermission(
225
- await request(
226
- "PUT",
227
- `${base}/${encodeURIComponent(permissionId)}`,
228
- { body: serializePermissionBody(patch) },
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
- users: {
246
- // The current principal. For an app-user JWT this is the logged-in
247
- // user; for an INTEGRATION API key it is the bound service account.
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
  }