@colixsystems/datastore-client 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,11 @@ See the design reference: [`docs/architecture/widget-marketplace.md`](../../docs
6
6
 
7
7
  ## Status
8
8
 
9
- `v0.2.0` — pre-publish. Not yet published to npm.
9
+ `v0.4.0` — pre-publish. Not yet published to npm.
10
+
11
+ > **0.4.0 (additive):** added the `records(t).permissions(recordId)` namespace — `list()`, `grant()`, `update(permissionId, patch)`, `revoke(permissionId)` — covering REQ-ACL-06 row-level grants. The client presents the camelCase shape (`userId`, `canRead`, …) and translates to the snake_case wire contract in both directions.
12
+
13
+ > **0.3.0 (breaking):** the client now matches the AppStudio backend's real REST contract. `records().list()` takes `{ limit, offset, q, filterMode, filter }` (the old `cursor` / JSON `filter` and `sort` are gone) and returns the `{ data, meta }` envelope. `records().aggregate({ groupBy, sumField, filter })` is a `GET` returning `[{ group, count, sum }]`. `users.me()` resolves the current principal via `/auth/app/me`. Filter values are `op:value` expressions.
10
14
 
11
15
  ## Differences from `frontend/src/api/client.js`
12
16
 
@@ -40,9 +44,51 @@ const client = createDatastoreClient({
40
44
  });
41
45
 
42
46
  const tables = await client.tables.list();
43
- const orders = await client.records("orders").list({ filter: { status: "PAID" } });
47
+
48
+ // Filter values are `op:value` expressions (eq, neq, lt, gt, gte, lte,
49
+ // contains, empty, nempty). Pagination is limit + offset.
50
+ const { data: orders, meta } = await client
51
+ .records("orders")
52
+ .list({ filter: { status: "eq:PAID" }, limit: 50, offset: 0 });
53
+
54
+ // Aggregate: group by one column, optionally sum one numeric field.
55
+ const byStatus = await client
56
+ .records("orders")
57
+ .aggregate({ groupBy: "status", sumField: "total" });
58
+ // → [{ group: "PAID", count: 12, sum: 4200 }, ...]
59
+
60
+ const me = await client.users.me(); // GET /auth/app/me
61
+ const myGroups = await client.groups.listMine(); // { data, meta }
62
+
63
+ // Row-level permissions (REQ-ACL-06): a grant to a user OR group with
64
+ // `canRead` is what membership means. Provide exactly one of userId/groupId.
65
+ const perms = client.records("channels").permissions(channelId);
66
+ const { data: members } = await perms.list();
67
+ const grant = await perms.grant({ userId: "user_123", canRead: true });
68
+ await perms.update(grant.id, { canWrite: true });
69
+ await perms.revoke(grant.id);
44
70
  ```
45
71
 
72
+ ## Surface
73
+
74
+ | Method | HTTP | Returns |
75
+ | --- | --- | --- |
76
+ | `tables.list()` | `GET /tables` | `Page<TableMeta>` |
77
+ | `tables.get(idOrName)` | `GET /tables/{id}` | `TableMeta` |
78
+ | `records(t).list(query?)` | `GET /tables/{t}/records` | `Page<Record>` |
79
+ | `records(t).get(id)` | `GET /tables/{t}/records/{id}` | `Record` |
80
+ | `records(t).create(values)` | `POST /tables/{t}/records` | `Record` |
81
+ | `records(t).update(id, values)` | `PATCH /tables/{t}/records/{id}` | `Record` |
82
+ | `records(t).delete(id)` | `DELETE /tables/{t}/records/{id}` | `void` |
83
+ | `records(t).aggregate(spec)` | `GET /tables/{t}/records/aggregate` | `AggregateResult` |
84
+ | `records(t).permissions(r).list()` | `GET /tables/{t}/records/{r}/permissions` | `Page<RecordPermission>` |
85
+ | `records(t).permissions(r).grant(grant)` | `POST /tables/{t}/records/{r}/permissions` | `RecordPermission` |
86
+ | `records(t).permissions(r).update(pid, patch)` | `PUT /tables/{t}/records/{r}/permissions/{pid}` | `RecordPermission` |
87
+ | `records(t).permissions(r).revoke(pid)` | `DELETE /tables/{t}/records/{r}/permissions/{pid}` | `void` |
88
+ | `users.me()` | `GET /auth/app/me` | `AppUser` |
89
+ | `users.get(id)` | `GET /app/users/{id}` | `AppUser` |
90
+ | `groups.listMine()` | `GET /app/groups/mine` | `Page<Group>` |
91
+
46
92
  ## Dependencies
47
93
 
48
94
  None. The client uses only platform `fetch` and `AbortController`, both available in modern browsers, Node 18+, and React Native.
package/dist/client.js CHANGED
@@ -12,23 +12,89 @@ function joinUrl(base, path) {
12
12
  return `${b}${p}`;
13
13
  }
14
14
 
15
+ // 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.
20
+ function appendFilter(parts, filter) {
21
+ if (!filter || typeof filter !== "object") return;
22
+ for (const [col, expr] of Object.entries(filter)) {
23
+ if (expr == null) continue;
24
+ parts.push(
25
+ `filter[${encodeURIComponent(col)}]=${encodeURIComponent(expr)}`,
26
+ );
27
+ }
28
+ }
29
+
30
+ // 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.
15
33
  function buildQueryString(query) {
16
34
  if (!query || typeof query !== "object") return "";
17
35
  const parts = [];
18
- if (query.limit != null) parts.push(`limit=${encodeURIComponent(query.limit)}`);
19
- if (query.cursor) parts.push(`cursor=${encodeURIComponent(query.cursor)}`);
20
- if (query.sort && Array.isArray(query.sort)) {
21
- const sortStr = query.sort
22
- .map((s) => `${s.dir === "desc" ? "-" : ""}${s.field}`)
23
- .join(",");
24
- if (sortStr) parts.push(`sort=${encodeURIComponent(sortStr)}`);
25
- }
26
- if (query.filter && typeof query.filter === "object") {
27
- parts.push(`filter=${encodeURIComponent(JSON.stringify(query.filter))}`);
36
+ if (query.limit != null)
37
+ parts.push(`limit=${encodeURIComponent(query.limit)}`);
38
+ if (query.offset != null)
39
+ parts.push(`offset=${encodeURIComponent(query.offset)}`);
40
+ if (query.q != null && query.q !== "")
41
+ parts.push(`q=${encodeURIComponent(query.q)}`);
42
+ if (query.filterMode === "or" || query.filterMode === "and") {
43
+ parts.push(`filter_mode=${query.filterMode}`);
28
44
  }
45
+ appendFilter(parts, query.filter);
46
+ return parts.length ? `?${parts.join("&")}` : "";
47
+ }
48
+
49
+ // 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.
52
+ function buildAggregateQueryString(spec) {
53
+ if (!spec || typeof spec !== "object") return "";
54
+ const parts = [];
55
+ if (spec.groupBy) parts.push(`group_by=${encodeURIComponent(spec.groupBy)}`);
56
+ if (spec.sumField)
57
+ parts.push(`sum_field=${encodeURIComponent(spec.sumField)}`);
58
+ appendFilter(parts, spec.filter);
29
59
  return parts.length ? `?${parts.join("&")}` : "";
30
60
  }
31
61
 
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
+
32
98
  /**
33
99
  * @param {object} opts
34
100
  * @param {string} opts.baseUrl
@@ -48,12 +114,14 @@ export function createDatastoreClient(opts) {
48
114
  throw new TypeError("createDatastoreClient: getToken must be a function");
49
115
  }
50
116
  if (typeof getTenantId !== "function") {
51
- throw new TypeError("createDatastoreClient: getTenantId must be a function");
117
+ throw new TypeError(
118
+ "createDatastoreClient: getTenantId must be a function",
119
+ );
52
120
  }
53
121
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
54
122
  if (typeof fetchImpl !== "function") {
55
123
  throw new TypeError(
56
- "createDatastoreClient: no fetch available. Pass fetchImpl or run in an environment with global fetch."
124
+ "createDatastoreClient: no fetch available. Pass fetchImpl or run in an environment with global fetch.",
57
125
  );
58
126
  }
59
127
 
@@ -63,7 +131,10 @@ export function createDatastoreClient(opts) {
63
131
  const tenantId = await getTenantId();
64
132
 
65
133
  const headers = { accept: "application/json" };
66
- if (token) headers.authorization = token.startsWith("Bearer ") ? token : `Bearer ${token}`;
134
+ if (token)
135
+ headers.authorization = token.startsWith("Bearer ")
136
+ ? token
137
+ : `Bearer ${token}`;
67
138
  if (tenantId) headers["x-tenant-id"] = tenantId;
68
139
  if (body !== undefined) headers["content-type"] = "application/json";
69
140
 
@@ -106,27 +177,79 @@ export function createDatastoreClient(opts) {
106
177
  }
107
178
  const enc = encodeURIComponent(table);
108
179
  return {
109
- list: (query) => request("GET", `/tables/${enc}/records${buildQueryString(query)}`),
110
- get: (id) => request("GET", `/tables/${enc}/records/${encodeURIComponent(id)}`),
111
- create: (values) => request("POST", `/tables/${enc}/records`, { body: values }),
180
+ list: (query) =>
181
+ request("GET", `/tables/${enc}/records${buildQueryString(query)}`),
182
+ get: (id) =>
183
+ request("GET", `/tables/${enc}/records/${encodeURIComponent(id)}`),
184
+ create: (values) =>
185
+ request("POST", `/tables/${enc}/records`, { body: values }),
112
186
  update: (id, values) =>
113
- request("PATCH", `/tables/${enc}/records/${encodeURIComponent(id)}`, { body: values }),
114
- delete: (id) => request("DELETE", `/tables/${enc}/records/${encodeURIComponent(id)}`),
115
- aggregate: (spec) => request("POST", `/tables/${enc}/aggregate`, { body: spec }),
187
+ request("PATCH", `/tables/${enc}/records/${encodeURIComponent(id)}`, {
188
+ body: values,
189
+ }),
190
+ delete: (id) =>
191
+ request("DELETE", `/tables/${enc}/records/${encodeURIComponent(id)}`),
192
+ aggregate: (spec) =>
193
+ request(
194
+ "GET",
195
+ `/tables/${enc}/records/aggregate${buildAggregateQueryString(spec)}`,
196
+ ),
197
+ // 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:
200
+ // GET …/permissions list grants ({ data, meta })
201
+ // POST …/permissions grant one (provide userId XOR groupId)
202
+ // PUT …/permissions/{permissionId} update one grant's flags
203
+ // DELETE …/permissions/{permissionId} revoke one grant
204
+ permissions: (recordId) => {
205
+ if (typeof recordId !== "string" || recordId.length === 0) {
206
+ throw new TypeError(
207
+ "records(table).permissions(recordId): recordId must be a non-empty string",
208
+ );
209
+ }
210
+ const base = `/tables/${enc}/records/${encodeURIComponent(recordId)}/permissions`;
211
+ 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
+ ),
231
+ revoke: (permissionId) =>
232
+ request("DELETE", `${base}/${encodeURIComponent(permissionId)}`),
233
+ };
234
+ },
116
235
  };
117
236
  }
118
237
 
119
238
  return {
120
239
  tables: {
121
240
  list: () => request("GET", `/tables`),
122
- get: (idOrName) => request("GET", `/tables/${encodeURIComponent(idOrName)}`),
241
+ get: (idOrName) =>
242
+ request("GET", `/tables/${encodeURIComponent(idOrName)}`),
123
243
  },
124
244
  records: recordsNs,
125
245
  users: {
126
- me: () => request("GET", `/app/users/me`),
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`),
127
249
  get: (id) => request("GET", `/app/users/${encodeURIComponent(id)}`),
128
250
  },
129
251
  groups: {
252
+ // The groups the calling principal belongs to.
130
253
  listMine: () => request("GET", `/app/groups/mine`),
131
254
  },
132
255
  };
@@ -0,0 +1,215 @@
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`.
5
+
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { createDatastoreClient } from "./client.js";
9
+
10
+ function makeClient(captured, responseBody = { data: [], meta: {} }) {
11
+ const fetchImpl = async (url, init) => {
12
+ captured.url = url;
13
+ captured.method = init.method;
14
+ captured.body = init.body;
15
+ return {
16
+ ok: true,
17
+ status: 200,
18
+ text: async () => JSON.stringify(responseBody),
19
+ };
20
+ };
21
+ return createDatastoreClient({
22
+ baseUrl: "https://api.example.com/api/v1",
23
+ getToken: () => "Bearer as_testkey",
24
+ getTenantId: () => "tenant-1",
25
+ fetchImpl,
26
+ });
27
+ }
28
+
29
+ test("records.list serialises limit/offset/q/filter_mode/filter[col]", async () => {
30
+ const cap = {};
31
+ const client = makeClient(cap);
32
+ await client.records("Tasks").list({
33
+ limit: 25,
34
+ offset: 50,
35
+ q: "urgent",
36
+ filterMode: "or",
37
+ filter: { status: "eq:Open", priority: "gte:3" },
38
+ });
39
+ assert.equal(cap.method, "GET");
40
+ const u = new URL(cap.url);
41
+ assert.equal(u.pathname, "/api/v1/tables/Tasks/records");
42
+ assert.equal(u.searchParams.get("limit"), "25");
43
+ assert.equal(u.searchParams.get("offset"), "50");
44
+ assert.equal(u.searchParams.get("q"), "urgent");
45
+ assert.equal(u.searchParams.get("filter_mode"), "or");
46
+ assert.equal(u.searchParams.get("filter[status]"), "eq:Open");
47
+ 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);
51
+ });
52
+
53
+ test("records.aggregate is a GET on /records/aggregate with group_by/sum_field", async () => {
54
+ const cap = {};
55
+ const client = makeClient(cap, []);
56
+ await client.records("Sales").aggregate({
57
+ groupBy: "region",
58
+ sumField: "amount",
59
+ filter: { status: "eq:Closed" },
60
+ });
61
+ assert.equal(cap.method, "GET");
62
+ assert.equal(cap.body, undefined);
63
+ const u = new URL(cap.url);
64
+ assert.equal(u.pathname, "/api/v1/tables/Sales/records/aggregate");
65
+ assert.equal(u.searchParams.get("group_by"), "region");
66
+ assert.equal(u.searchParams.get("sum_field"), "amount");
67
+ assert.equal(u.searchParams.get("filter[status]"), "eq:Closed");
68
+ });
69
+
70
+ test("record CRUD verbs + paths", async () => {
71
+ const cap = {};
72
+ const client = makeClient(cap, { id: "r1" });
73
+ const ns = client.records("Tasks");
74
+
75
+ await ns.get("r1");
76
+ assert.equal(cap.method, "GET");
77
+ assert.equal(new URL(cap.url).pathname, "/api/v1/tables/Tasks/records/r1");
78
+
79
+ await ns.create({ title: "x" });
80
+ assert.equal(cap.method, "POST");
81
+ assert.equal(new URL(cap.url).pathname, "/api/v1/tables/Tasks/records");
82
+
83
+ await ns.update("r1", { title: "y" });
84
+ assert.equal(cap.method, "PATCH");
85
+ assert.equal(new URL(cap.url).pathname, "/api/v1/tables/Tasks/records/r1");
86
+
87
+ await ns.delete("r1");
88
+ assert.equal(cap.method, "DELETE");
89
+ assert.equal(new URL(cap.url).pathname, "/api/v1/tables/Tasks/records/r1");
90
+ });
91
+
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 () => {
101
+ 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, {
126
+ data: [
127
+ {
128
+ id: "p1",
129
+ table_id: "Channels",
130
+ record_id: "c1",
131
+ user_id: "u1",
132
+ group_id: null,
133
+ can_read: true,
134
+ can_write: false,
135
+ can_delete: false,
136
+ can_grant: false,
137
+ source_kind: null,
138
+ source_id: null,
139
+ created_at: "2026-01-01T00:00:00Z",
140
+ updated_at: "2026-01-02T00:00:00Z",
141
+ },
142
+ ],
143
+ meta: { total: 1, limit: 1, offset: 0 },
144
+ });
145
+ const res = await client.records("Channels").permissions("c1").list();
146
+ assert.equal(cap.method, "GET");
147
+ assert.equal(
148
+ new URL(cap.url).pathname,
149
+ "/api/v1/tables/Channels/records/c1/permissions",
150
+ );
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);
156
+ });
157
+
158
+ test("records.permissions.grant POSTs a snake_case body", async () => {
159
+ const cap = {};
160
+ const client = makeClient(cap, {
161
+ id: "p2",
162
+ table_id: "Channels",
163
+ record_id: "c1",
164
+ user_id: "u2",
165
+ can_read: true,
166
+ can_write: true,
167
+ });
168
+ const row = await client
169
+ .records("Channels")
170
+ .permissions("c1")
171
+ .grant({ userId: "u2", canRead: true, canWrite: true });
172
+ assert.equal(cap.method, "POST");
173
+ assert.equal(
174
+ new URL(cap.url).pathname,
175
+ "/api/v1/tables/Channels/records/c1/permissions",
176
+ );
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);
183
+ });
184
+
185
+ test("records.permissions.update PUTs flags by permissionId", async () => {
186
+ const cap = {};
187
+ const client = makeClient(cap, { id: "p2", can_delete: true });
188
+ await client
189
+ .records("Channels")
190
+ .permissions("c1")
191
+ .update("p2", { canDelete: true });
192
+ assert.equal(cap.method, "PUT");
193
+ assert.equal(
194
+ new URL(cap.url).pathname,
195
+ "/api/v1/tables/Channels/records/c1/permissions/p2",
196
+ );
197
+ assert.deepEqual(JSON.parse(cap.body), { can_delete: true });
198
+ });
199
+
200
+ test("records.permissions.revoke DELETEs by permissionId", async () => {
201
+ const cap = {};
202
+ const client = makeClient(cap, {});
203
+ await client.records("Channels").permissions("c1").revoke("p2");
204
+ assert.equal(cap.method, "DELETE");
205
+ assert.equal(
206
+ new URL(cap.url).pathname,
207
+ "/api/v1/tables/Channels/records/c1/permissions/p2",
208
+ );
209
+ });
210
+
211
+ test("records.permissions requires a non-empty recordId", () => {
212
+ const cap = {};
213
+ const client = makeClient(cap);
214
+ assert.throws(() => client.records("Channels").permissions(""), TypeError);
215
+ });
package/dist/index.d.ts CHANGED
@@ -19,43 +19,118 @@ export interface Record_ {
19
19
  }
20
20
  export { Record_ as Record };
21
21
 
22
+ // The uniform list envelope every collection endpoint returns
23
+ // (`{ data, meta }`). `meta.total` is the ACL-aware row count; use it
24
+ // with `limit`/`offset` to page.
22
25
  export interface Page<T> {
23
26
  data: T[];
24
- nextCursor?: string | null;
27
+ meta: {
28
+ total: number;
29
+ limit: number;
30
+ offset: number;
31
+ };
25
32
  }
26
33
 
34
+ // Record-list query. A `filter` maps a column name to an `op:value`
35
+ // expression — e.g. `{ status: "eq:Open", priority: "gte:3" }`. Supported
36
+ // operators: eq, neq, lt, gt, gte, lte, contains, empty, nempty (and the
37
+ // `me` sentinel for USER columns). `filterMode` ANDs conditions by
38
+ // default, or ORs them. `q` is a free-text search across the table's
39
+ // string/text columns. Pagination is `limit` + `offset`.
27
40
  export interface Query {
28
- filter?: Record<string, unknown>;
29
- sort?: Array<{ field: string; dir: "asc" | "desc" }>;
41
+ filter?: Record<string, string>;
42
+ filterMode?: "and" | "or";
43
+ q?: string;
30
44
  limit?: number;
31
- cursor?: string;
45
+ offset?: number;
32
46
  }
33
47
 
48
+ // Aggregate request: group rows by a single column and (optionally) sum a
49
+ // single numeric field, with the same `filter` map the list accepts.
34
50
  export interface AggregateSpec {
35
- groupBy?: string[];
36
- metrics: Array<{
37
- field?: string;
38
- op: "count" | "sum" | "avg" | "min" | "max";
39
- alias?: string;
40
- }>;
41
- filter?: Record<string, unknown>;
51
+ groupBy?: string;
52
+ sumField?: string;
53
+ filter?: Record<string, string>;
42
54
  }
43
55
 
44
- export interface AggregateResult {
45
- rows: Array<Record<string, unknown>>;
46
- }
56
+ // Aggregate response: one row per group with the row count and the sum of
57
+ // `sumField` (0 when no `sumField` was requested).
58
+ export type AggregateResult = Array<{
59
+ group: string;
60
+ count: number;
61
+ sum: number;
62
+ }>;
47
63
 
64
+ // The principal returned by `users.me()` (`GET /auth/app/me`). `email` is
65
+ // present for the logged-in user; the directory projection from
66
+ // `users.get(id)` returns `{ id, name, role }` for non-Studio callers.
48
67
  export interface AppUser {
49
68
  id: string;
50
- email: string | null;
51
- displayName: string | null;
52
- roles: string[];
53
- groupIds: string[];
69
+ name: string | null;
70
+ email?: string | null;
71
+ role?: string;
72
+ groupIds?: string[];
54
73
  }
55
74
 
56
75
  export interface Group {
57
76
  id: string;
58
77
  name: string;
78
+ tenantId?: string;
79
+ createdAt?: string;
80
+ updatedAt?: string;
81
+ }
82
+
83
+ // A single row-level grant on a record (REQ-ACL-06). Exactly one of
84
+ // `userId` / `groupId` is set; the four `can*` flags are the capability
85
+ // bits. `sourceKind` / `sourceId` are non-null only for grants cascaded
86
+ // from a parent record through an inheriting relation — directly-authored
87
+ // grants have both null.
88
+ export interface RecordPermission {
89
+ id: string;
90
+ tableId: string;
91
+ recordId: string | null;
92
+ userId: string | null;
93
+ groupId: string | null;
94
+ canRead: boolean;
95
+ canWrite: boolean;
96
+ canDelete: boolean;
97
+ canGrant: boolean;
98
+ sourceKind: string | null;
99
+ sourceId: string | null;
100
+ createdAt: string;
101
+ updatedAt: string;
102
+ }
103
+
104
+ // Grant body for `permissions(recordId).grant(...)`. Provide exactly ONE of
105
+ // `userId` or `groupId`. Flags default server-side to `canRead: true` and
106
+ // the rest `false` when omitted.
107
+ export interface RecordPermissionGrant {
108
+ userId?: string | null;
109
+ groupId?: string | null;
110
+ canRead?: boolean;
111
+ canWrite?: boolean;
112
+ canDelete?: boolean;
113
+ canGrant?: boolean;
114
+ }
115
+
116
+ // Flag patch for `permissions(recordId).update(...)`. Only the four
117
+ // capability bits are mutable; the grantee is fixed at grant time. Omitted
118
+ // flags are left unchanged.
119
+ export interface RecordPermissionPatch {
120
+ canRead?: boolean;
121
+ canWrite?: boolean;
122
+ canDelete?: boolean;
123
+ canGrant?: boolean;
124
+ }
125
+
126
+ export interface RecordPermissionsNamespace {
127
+ list(): Promise<Page<RecordPermission>>;
128
+ grant(grant: RecordPermissionGrant): Promise<RecordPermission>;
129
+ update(
130
+ permissionId: string,
131
+ patch: RecordPermissionPatch,
132
+ ): Promise<RecordPermission>;
133
+ revoke(permissionId: string): Promise<void>;
59
134
  }
60
135
 
61
136
  export interface RecordsNamespace {
@@ -65,6 +140,7 @@ export interface RecordsNamespace {
65
140
  update(id: string, values: Partial<Record_>): Promise<Record_>;
66
141
  delete(id: string): Promise<void>;
67
142
  aggregate(spec: AggregateSpec): Promise<AggregateResult>;
143
+ permissions(recordId: string): RecordPermissionsNamespace;
68
144
  }
69
145
 
70
146
  export interface DatastoreClient {
@@ -90,7 +166,7 @@ export interface CreateDatastoreClientOptions {
90
166
  }
91
167
 
92
168
  export function createDatastoreClient(
93
- opts: CreateDatastoreClientOptions
169
+ opts: CreateDatastoreClientOptions,
94
170
  ): DatastoreClient;
95
171
 
96
172
  export class DatastoreError extends Error {
@@ -99,7 +175,7 @@ export class DatastoreError extends Error {
99
175
  details: unknown;
100
176
  constructor(
101
177
  message: string,
102
- init?: { code?: string; status?: number; details?: unknown }
178
+ init?: { code?: string; status?: number; details?: unknown },
103
179
  );
104
180
  }
105
181
  export class NotFoundError extends DatastoreError {}
@@ -108,9 +184,12 @@ export class ValidationError extends DatastoreError {}
108
184
  export class RateLimitedError extends DatastoreError {}
109
185
  export class ServerError extends DatastoreError {}
110
186
 
111
- export function errorFromResponse(status: number, body: unknown): DatastoreError;
187
+ export function errorFromResponse(
188
+ status: number,
189
+ body: unknown,
190
+ ): DatastoreError;
112
191
 
113
192
  export function withRetry<T>(
114
193
  opts: { method: string; timeoutMs?: number },
115
- attempt: (signal: AbortSignal) => Promise<T>
194
+ attempt: (signal: AbortSignal) => Promise<T>,
116
195
  ): Promise<T>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/datastore-client",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Typed, scoped client for the AppStudio datastore API. Used by widgets through the injected WidgetContext.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",