@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 +48 -2
- package/dist/client.js +144 -21
- package/dist/client.test.js +215 -0
- package/dist/index.d.ts +101 -22
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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)
|
|
19
|
-
|
|
20
|
-
if (query.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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(
|
|
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)
|
|
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) =>
|
|
110
|
-
|
|
111
|
-
|
|
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)}`, {
|
|
114
|
-
|
|
115
|
-
|
|
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) =>
|
|
241
|
+
get: (idOrName) =>
|
|
242
|
+
request("GET", `/tables/${encodeURIComponent(idOrName)}`),
|
|
123
243
|
},
|
|
124
244
|
records: recordsNs,
|
|
125
245
|
users: {
|
|
126
|
-
|
|
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
|
-
|
|
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,
|
|
29
|
-
|
|
41
|
+
filter?: Record<string, string>;
|
|
42
|
+
filterMode?: "and" | "or";
|
|
43
|
+
q?: string;
|
|
30
44
|
limit?: number;
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
groupIds
|
|
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(
|
|
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