@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.
@@ -1,17 +1,18 @@
1
1
  // Wire-contract tests for createDatastoreClient. A capturing fetch
2
- // implementation records the method + URL each namespace method emits so
3
- // we lock the client to the backend's real REST surface (REQ-OPS-DOC-EXT
4
- // reconciliation). These run under `node --test`.
2
+ // implementation records the method + URL + body each namespace method emits
3
+ // so we lock the client to the backend's real snake_case REST surface
4
+ // (REQ-GEN-09). These run under `node --test` with NO npm install.
5
5
 
6
6
  import { test } from "node:test";
7
7
  import assert from "node:assert/strict";
8
8
  import { createDatastoreClient } from "./client.js";
9
9
 
10
- function makeClient(captured, responseBody = { data: [], meta: {} }) {
10
+ function makeClient(captured, responseBody = { data: [], meta: {} }, extra = {}) {
11
11
  const fetchImpl = async (url, init) => {
12
12
  captured.url = url;
13
13
  captured.method = init.method;
14
14
  captured.body = init.body;
15
+ captured.headers = init.headers;
15
16
  return {
16
17
  ok: true,
17
18
  status: 200,
@@ -23,10 +24,63 @@ function makeClient(captured, responseBody = { data: [], meta: {} }) {
23
24
  getToken: () => "Bearer as_testkey",
24
25
  getTenantId: () => "tenant-1",
25
26
  fetchImpl,
27
+ ...extra,
26
28
  });
27
29
  }
28
30
 
29
- test("records.list serialises limit/offset/q/filter_mode/filter[col]", async () => {
31
+ test("factory validates opts", () => {
32
+ assert.throws(() => createDatastoreClient(), TypeError);
33
+ assert.throws(
34
+ () => createDatastoreClient({ getToken: () => "", getTenantId: () => "" }),
35
+ TypeError,
36
+ );
37
+ assert.throws(
38
+ () => createDatastoreClient({ baseUrl: "x", getTenantId: () => "" }),
39
+ TypeError,
40
+ );
41
+ assert.throws(
42
+ () => createDatastoreClient({ baseUrl: "x", getToken: () => "" }),
43
+ TypeError,
44
+ );
45
+ assert.throws(
46
+ () =>
47
+ createDatastoreClient({
48
+ baseUrl: "x",
49
+ getToken: () => "",
50
+ getTenantId: () => "",
51
+ getRequestHeaders: "nope",
52
+ }),
53
+ TypeError,
54
+ );
55
+ });
56
+
57
+ test("tables.list / tables.get paths return the envelope/schema verbatim", async () => {
58
+ const cap = {};
59
+ const client = makeClient(cap, { data: [{ id: "t1", name: "Tasks" }], meta: { total: 1, limit: 50, offset: 0 } });
60
+ const list = await client.tables.list();
61
+ assert.equal(cap.method, "GET");
62
+ assert.equal(new URL(cap.url).pathname, "/api/v1/tables");
63
+ assert.deepEqual(list, { data: [{ id: "t1", name: "Tasks" }], meta: { total: 1, limit: 50, offset: 0 } });
64
+
65
+ const tableSchema = { id: "t1", name: "Tasks", columns: [{ id: "c1", table_id: "t1", name: "title", data_type: "STRING" }] };
66
+ const cap2 = {};
67
+ const client2 = makeClient(cap2, tableSchema);
68
+ const got = await client2.tables.get("Tasks");
69
+ assert.equal(new URL(cap2.url).pathname, "/api/v1/tables/Tasks");
70
+ assert.deepEqual(got, tableSchema);
71
+ });
72
+
73
+ test("schema(t) is an alias of tables.get", async () => {
74
+ const cap = {};
75
+ const tableSchema = { id: "t1", name: "Tasks", columns: [] };
76
+ const client = makeClient(cap, tableSchema);
77
+ const got = await client.schema("t1");
78
+ assert.equal(cap.method, "GET");
79
+ assert.equal(new URL(cap.url).pathname, "/api/v1/tables/t1");
80
+ assert.deepEqual(got, tableSchema);
81
+ });
82
+
83
+ test("records.list serialises limit/offset/q/filter_mode/sort/filter[col]", async () => {
30
84
  const cap = {};
31
85
  const client = makeClient(cap);
32
86
  await client.records("Tasks").list({
@@ -34,6 +88,7 @@ test("records.list serialises limit/offset/q/filter_mode/filter[col]", async ()
34
88
  offset: 50,
35
89
  q: "urgent",
36
90
  filterMode: "or",
91
+ sort: "-created_at",
37
92
  filter: { status: "eq:Open", priority: "gte:3" },
38
93
  });
39
94
  assert.equal(cap.method, "GET");
@@ -43,17 +98,26 @@ test("records.list serialises limit/offset/q/filter_mode/filter[col]", async ()
43
98
  assert.equal(u.searchParams.get("offset"), "50");
44
99
  assert.equal(u.searchParams.get("q"), "urgent");
45
100
  assert.equal(u.searchParams.get("filter_mode"), "or");
101
+ assert.equal(u.searchParams.get("sort"), "-created_at");
46
102
  assert.equal(u.searchParams.get("filter[status]"), "eq:Open");
47
103
  assert.equal(u.searchParams.get("filter[priority]"), "gte:3");
48
- // The legacy `cursor` / JSON `filter=` forms must be gone.
49
- assert.equal(u.searchParams.get("cursor"), null);
50
- assert.equal(u.searchParams.get("filter"), null);
104
+ });
105
+
106
+ test("records.list returns the { data, meta } envelope verbatim (no unwrap)", async () => {
107
+ const cap = {};
108
+ const body = {
109
+ data: [{ id: "r1", title: "x", created_at: "2026-01-01T00:00:00Z" }],
110
+ meta: { total: 1, limit: 50, offset: 0 },
111
+ };
112
+ const client = makeClient(cap, body);
113
+ const res = await client.records("Tasks").list();
114
+ assert.deepEqual(res, body);
51
115
  });
52
116
 
53
117
  test("records.aggregate is a GET on /records/aggregate with group_by/sum_field", async () => {
54
118
  const cap = {};
55
119
  const client = makeClient(cap, []);
56
- await client.records("Sales").aggregate({
120
+ const res = await client.records("Sales").aggregate({
57
121
  groupBy: "region",
58
122
  sumField: "amount",
59
123
  filter: { status: "eq:Closed" },
@@ -65,9 +129,10 @@ test("records.aggregate is a GET on /records/aggregate with group_by/sum_field",
65
129
  assert.equal(u.searchParams.get("group_by"), "region");
66
130
  assert.equal(u.searchParams.get("sum_field"), "amount");
67
131
  assert.equal(u.searchParams.get("filter[status]"), "eq:Closed");
132
+ assert.deepEqual(res, []);
68
133
  });
69
134
 
70
- test("record CRUD verbs + paths", async () => {
135
+ test("record CRUD verbs + paths; create/update send the body verbatim", async () => {
71
136
  const cap = {};
72
137
  const client = makeClient(cap, { id: "r1" });
73
138
  const ns = client.records("Tasks");
@@ -76,53 +141,24 @@ test("record CRUD verbs + paths", async () => {
76
141
  assert.equal(cap.method, "GET");
77
142
  assert.equal(new URL(cap.url).pathname, "/api/v1/tables/Tasks/records/r1");
78
143
 
79
- await ns.create({ title: "x" });
144
+ await ns.create({ title: "x", due_date: "2026-01-01" });
80
145
  assert.equal(cap.method, "POST");
81
146
  assert.equal(new URL(cap.url).pathname, "/api/v1/tables/Tasks/records");
147
+ assert.deepEqual(JSON.parse(cap.body), { title: "x", due_date: "2026-01-01" });
82
148
 
83
149
  await ns.update("r1", { title: "y" });
84
150
  assert.equal(cap.method, "PATCH");
85
151
  assert.equal(new URL(cap.url).pathname, "/api/v1/tables/Tasks/records/r1");
152
+ assert.deepEqual(JSON.parse(cap.body), { title: "y" });
86
153
 
87
154
  await ns.delete("r1");
88
155
  assert.equal(cap.method, "DELETE");
89
156
  assert.equal(new URL(cap.url).pathname, "/api/v1/tables/Tasks/records/r1");
90
157
  });
91
158
 
92
- test("users.me targets /auth/app/me (not /app/users/me)", async () => {
93
- const cap = {};
94
- const client = makeClient(cap, { id: "u1", name: "A", email: "a@b.c" });
95
- await client.users.me();
96
- assert.equal(cap.method, "GET");
97
- assert.equal(new URL(cap.url).pathname, "/api/v1/auth/app/me");
98
- });
99
-
100
- test("users.get targets /app/users/{id}", async () => {
159
+ test("records.permissions.list returns { data, meta } snake_case verbatim", async () => {
101
160
  const cap = {};
102
- const client = makeClient(cap, { id: "u9", name: "B" });
103
- await client.users.get("u9");
104
- assert.equal(new URL(cap.url).pathname, "/api/v1/app/users/u9");
105
- });
106
-
107
- test("groups.listMine targets /app/groups/mine", async () => {
108
- const cap = {};
109
- const client = makeClient(cap);
110
- await client.groups.listMine();
111
- assert.equal(new URL(cap.url).pathname, "/api/v1/app/groups/mine");
112
- });
113
-
114
- test("tables.list / tables.get paths", async () => {
115
- const cap = {};
116
- const client = makeClient(cap);
117
- await client.tables.list();
118
- assert.equal(new URL(cap.url).pathname, "/api/v1/tables");
119
- await client.tables.get("Tasks");
120
- assert.equal(new URL(cap.url).pathname, "/api/v1/tables/Tasks");
121
- });
122
-
123
- test("records.permissions.list maps {data,meta} rows to camelCase", async () => {
124
- const cap = {};
125
- const client = makeClient(cap, {
161
+ const body = {
126
162
  data: [
127
163
  {
128
164
  id: "p1",
@@ -141,54 +177,61 @@ test("records.permissions.list maps {data,meta} rows to camelCase", async () =>
141
177
  },
142
178
  ],
143
179
  meta: { total: 1, limit: 1, offset: 0 },
144
- });
180
+ };
181
+ const client = makeClient(cap, body);
145
182
  const res = await client.records("Channels").permissions("c1").list();
146
183
  assert.equal(cap.method, "GET");
147
184
  assert.equal(
148
185
  new URL(cap.url).pathname,
149
186
  "/api/v1/tables/Channels/records/c1/permissions",
150
187
  );
151
- assert.deepEqual(res.meta, { total: 1, limit: 1, offset: 0 });
152
- assert.equal(res.data[0].tableId, "Channels");
153
- assert.equal(res.data[0].userId, "u1");
154
- assert.equal(res.data[0].canRead, true);
155
- assert.equal(res.data[0].canWrite, false);
188
+ // No transform the row is returned snake_case verbatim.
189
+ assert.deepEqual(res, body);
156
190
  });
157
191
 
158
- test("records.permissions.grant POSTs a snake_case body", async () => {
192
+ test("records.permissions.grant POSTs the snake_case body verbatim and returns the row verbatim", async () => {
159
193
  const cap = {};
160
- const client = makeClient(cap, {
194
+ const row = {
161
195
  id: "p2",
162
196
  table_id: "Channels",
163
197
  record_id: "c1",
164
198
  user_id: "u2",
199
+ group_id: null,
165
200
  can_read: true,
166
201
  can_write: true,
167
- });
168
- const row = await client
202
+ can_delete: false,
203
+ can_grant: false,
204
+ source_kind: null,
205
+ source_id: null,
206
+ created_at: "2026-01-01T00:00:00Z",
207
+ updated_at: "2026-01-01T00:00:00Z",
208
+ };
209
+ const client = makeClient(cap, row);
210
+ const out = await client
169
211
  .records("Channels")
170
212
  .permissions("c1")
171
- .grant({ userId: "u2", canRead: true, canWrite: true });
213
+ .grant({ user_id: "u2", can_read: true, can_write: true });
172
214
  assert.equal(cap.method, "POST");
173
215
  assert.equal(
174
216
  new URL(cap.url).pathname,
175
217
  "/api/v1/tables/Channels/records/c1/permissions",
176
218
  );
177
- const sent = JSON.parse(cap.body);
178
- assert.deepEqual(sent, { user_id: "u2", can_read: true, can_write: true });
179
- // Response is mapped back to camelCase.
180
- assert.equal(row.id, "p2");
181
- assert.equal(row.userId, "u2");
182
- assert.equal(row.canWrite, true);
219
+ // Body is forwarded verbatim — no camel->snake mapping.
220
+ assert.deepEqual(JSON.parse(cap.body), {
221
+ user_id: "u2",
222
+ can_read: true,
223
+ can_write: true,
224
+ });
225
+ assert.deepEqual(out, row);
183
226
  });
184
227
 
185
- test("records.permissions.update PUTs flags by permissionId", async () => {
228
+ test("records.permissions.update PUTs flags by permissionId, verbatim", async () => {
186
229
  const cap = {};
187
230
  const client = makeClient(cap, { id: "p2", can_delete: true });
188
231
  await client
189
232
  .records("Channels")
190
233
  .permissions("c1")
191
- .update("p2", { canDelete: true });
234
+ .update("p2", { can_delete: true });
192
235
  assert.equal(cap.method, "PUT");
193
236
  assert.equal(
194
237
  new URL(cap.url).pathname,
@@ -213,3 +256,195 @@ test("records.permissions requires a non-empty recordId", () => {
213
256
  const client = makeClient(cap);
214
257
  assert.throws(() => client.records("Channels").permissions(""), TypeError);
215
258
  });
259
+
260
+ test("records(table) requires a non-empty table", () => {
261
+ const cap = {};
262
+ const client = makeClient(cap);
263
+ assert.throws(() => client.records(""), TypeError);
264
+ });
265
+
266
+ test("request sends auth + tenant headers; adds Bearer prefix when missing", async () => {
267
+ const cap = {};
268
+ const client = makeClient(cap, { data: [], meta: {} }, {
269
+ getToken: () => "as_rawkey",
270
+ });
271
+ await client.tables.list();
272
+ assert.equal(cap.headers.authorization, "Bearer as_rawkey");
273
+ assert.equal(cap.headers["x-tenant-id"], "tenant-1");
274
+ assert.equal(cap.headers.accept, "application/json");
275
+ });
276
+
277
+ test("getRequestHeaders is invoked per request with {namespace, operation} and merged", async () => {
278
+ const cap = {};
279
+ const seen = [];
280
+ const client = makeClient(cap, { data: [], meta: {} }, {
281
+ getRequestHeaders: ({ namespace, operation }) => {
282
+ seen.push(`${namespace}:${operation}`);
283
+ return { "X-Widget-Scopes": "records:read" };
284
+ },
285
+ });
286
+ await client.records("Tasks").list();
287
+ assert.equal(cap.headers["X-Widget-Scopes"], "records:read");
288
+ assert.deepEqual(seen, ["records:list"]);
289
+
290
+ await client.records("Tasks").permissions("r1").grant({ user_id: "u1" });
291
+ assert.deepEqual(seen, ["records:list", "permissions:grant"]);
292
+ });
293
+
294
+ test("data-plane only: no users/groups/files/payments namespaces", () => {
295
+ const cap = {};
296
+ const client = makeClient(cap);
297
+ assert.equal(client.users, undefined);
298
+ assert.equal(client.groups, undefined);
299
+ assert.equal(client.files, undefined);
300
+ assert.equal(client.payments, undefined);
301
+ });
302
+
303
+ test("non-OK responses throw a typed error", async () => {
304
+ const fetchImpl = async () => ({
305
+ ok: false,
306
+ status: 404,
307
+ text: async () => JSON.stringify({ error: { message: "nope" } }),
308
+ });
309
+ const client = createDatastoreClient({
310
+ baseUrl: "https://api.example.com/api/v1",
311
+ getToken: () => "Bearer x",
312
+ getTenantId: () => "t1",
313
+ fetchImpl,
314
+ });
315
+ await assert.rejects(() => client.records("Tasks").get("r1"), (err) => {
316
+ assert.equal(err.status, 404);
317
+ assert.equal(err.code, "NOT_FOUND");
318
+ return true;
319
+ });
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
+ });