@colixsystems/datastore-client 0.4.0 → 0.5.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 +89 -34
- package/dist/client.js +116 -93
- package/dist/client.test.js +168 -63
- package/dist/index.d.ts +99 -77
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,27 +1,36 @@
|
|
|
1
1
|
# @colixsystems/datastore-client
|
|
2
2
|
|
|
3
|
-
Typed, scoped client for the [AppStudio](https://github.com/appstudio) datastore API.
|
|
3
|
+
Typed, scoped **data-plane** client for the [AppStudio](https://github.com/appstudio) datastore API. It covers exactly three things:
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- **tables** — table schema (id, name, columns) via `tables.{list,get}` and the `schema(tableId)` alias.
|
|
6
|
+
- **records** — record CRUD, querying, and aggregation via `records(tableId).{list,get,create,update,delete,aggregate}`.
|
|
7
|
+
- **record-level permissions** — RLS grants on a single record (REQ-ACL-06) via `records(tableId).permissions(recordId).{list,grant,update,revoke}`.
|
|
8
|
+
|
|
9
|
+
It does **not** cover users, groups, files, or payments — those live in sibling packages ([`files-client`](../files-client), [`directory-client`](../directory-client), [`payments-client`](../payments-client)). This is a standalone `fetch`-based client you instantiate yourself with `createDatastoreClient({ baseUrl, getToken, getTenantId })`.
|
|
10
|
+
|
|
11
|
+
> **Two surfaces, one package.** This client serves two callers:
|
|
12
|
+
> 1. **External / server-side integrations** instantiate it directly with an API key (`getToken` returns the key, `getTenantId` the tenant) and call the methods below.
|
|
13
|
+
> 2. **The widget runtime** — the Player and exported Expo app instantiate the *same* package and inject it into `WidgetContext.datastore`. Widgets never import this package; they call the SDK hooks from `@colixsystems/widget-sdk` (`useDatastoreQuery`, `useDatastoreMutation`, `useDatastoreSchema`, `useRecordPermissions`, …), which read `ctx.datastore`. Both surfaces speak the identical snake_case REST contract.
|
|
6
14
|
|
|
7
15
|
## Status
|
|
8
16
|
|
|
9
|
-
`v0.
|
|
17
|
+
`v0.5.0` — pre-publish. Not yet published to npm.
|
|
10
18
|
|
|
11
|
-
> **0.
|
|
19
|
+
> **0.5.0 (breaking):** the client is now **snake_case end to end with NO transform** (REQ-GEN-09) and is scoped to the **data plane only**.
|
|
20
|
+
> - The old camelCase↔snake_case permission mappers are **deleted**. Permission bodies are sent snake_case verbatim (`{ user_id, can_read, … }`) and rows are returned snake_case verbatim (`{ id, table_id, can_read, … }`).
|
|
21
|
+
> - `records().list()` returns the `{ data, meta }` envelope verbatim (no unwrap). `tables.list()` likewise.
|
|
22
|
+
> - Added `schema(tableId)` as an alias of `tables.get` for the column structure (the host calls `ctx.datastore.schema(t)`).
|
|
23
|
+
> - Added an optional `getRequestHeaders({ namespace, operation })` factory option for attaching per-request headers (e.g. per-widget scope tokens) without the SDK knowing about scope-token issuance.
|
|
24
|
+
> - `record list` now accepts a `sort` param (e.g. `-created_at`).
|
|
25
|
+
> - **Removed** the `users` / `groups` namespaces — they belong in sibling packages.
|
|
12
26
|
|
|
13
|
-
|
|
27
|
+
## Contract: snake_case, no transform
|
|
14
28
|
|
|
15
|
-
|
|
29
|
+
The wire format is snake_case in **both** directions and that is the client contract too. The SDK does **no** case mapping:
|
|
16
30
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
| Tenant header | Pulled from store | Injected by host; widget cannot override |
|
|
21
|
-
| Retries | None | Idempotent GETs retried 3x with exponential backoff |
|
|
22
|
-
| Timeouts | Browser default | 10s default, configurable per call |
|
|
23
|
-
| Error model | Raw axios error | Typed `DatastoreError` hierarchy |
|
|
24
|
-
| Platform | Browser only | Browser **and** React Native (uses `fetch`) |
|
|
31
|
+
- Request bodies are sent **snake_case verbatim** — you pass `{ user_id, can_read }`, not `{ userId, canRead }`.
|
|
32
|
+
- Response objects are returned **snake_case verbatim** — you read `row.created_at`, `row.table_id`, `perm.can_read`.
|
|
33
|
+
- The only camelCase is JS method names (`filterMode`, `groupBy`, `sumField`) and factory option names.
|
|
25
34
|
|
|
26
35
|
## Public API
|
|
27
36
|
|
|
@@ -38,34 +47,40 @@ import {
|
|
|
38
47
|
|
|
39
48
|
const client = createDatastoreClient({
|
|
40
49
|
baseUrl: "https://api.appstudio.io",
|
|
41
|
-
getToken: () => "Bearer ...",
|
|
50
|
+
getToken: () => "Bearer ...", // "Bearer " prefix added if missing
|
|
42
51
|
getTenantId: () => "tenant_abc",
|
|
52
|
+
getRequestHeaders: ({ namespace, operation }) => ({ "X-Widget-Scopes": "..." }), // optional
|
|
43
53
|
// fetchImpl defaults to globalThis.fetch
|
|
44
54
|
});
|
|
45
55
|
|
|
46
|
-
|
|
56
|
+
// Tables (schema)
|
|
57
|
+
const { data: tables, meta } = await client.tables.list(); // { data, meta } verbatim
|
|
58
|
+
const table = await client.tables.get("Tasks"); // { id, name, columns: [...] }
|
|
59
|
+
const sameTable = await client.schema("Tasks"); // alias of tables.get
|
|
47
60
|
|
|
48
|
-
//
|
|
49
|
-
// contains, empty, nempty). Pagination is limit + offset.
|
|
50
|
-
const { data: orders
|
|
61
|
+
// Records — filter values are `op:value` expressions (eq, neq, lt, gt, gte,
|
|
62
|
+
// lte, contains, empty, nempty). Pagination is limit + offset.
|
|
63
|
+
const { data: orders } = await client
|
|
51
64
|
.records("orders")
|
|
52
|
-
.list({ filter: { status: "eq:PAID" }, limit: 50, offset: 0 });
|
|
65
|
+
.list({ filter: { status: "eq:PAID" }, sort: "-created_at", limit: 50, offset: 0 });
|
|
66
|
+
|
|
67
|
+
const rec = await client.records("orders").get("r1");
|
|
68
|
+
await client.records("orders").create({ status: "PAID", amount_cents: 1200 });
|
|
69
|
+
await client.records("orders").update("r1", { status: "REFUNDED" }); // PATCH
|
|
70
|
+
await client.records("orders").delete("r1");
|
|
53
71
|
|
|
54
72
|
// Aggregate: group by one column, optionally sum one numeric field.
|
|
55
73
|
const byStatus = await client
|
|
56
74
|
.records("orders")
|
|
57
|
-
.aggregate({ groupBy: "status", sumField: "
|
|
75
|
+
.aggregate({ groupBy: "status", sumField: "amount_cents" });
|
|
58
76
|
// → [{ group: "PAID", count: 12, sum: 4200 }, ...]
|
|
59
77
|
|
|
60
|
-
const me = await client.users.me(); // GET /auth/app/me
|
|
61
|
-
const myGroups = await client.groups.listMine(); // { data, meta }
|
|
62
|
-
|
|
63
78
|
// Row-level permissions (REQ-ACL-06): a grant to a user OR group with
|
|
64
|
-
//
|
|
79
|
+
// can_read is what membership means. Provide exactly one of user_id/group_id.
|
|
65
80
|
const perms = client.records("channels").permissions(channelId);
|
|
66
|
-
const { data: members } = await perms.list();
|
|
67
|
-
const grant = await perms.grant({
|
|
68
|
-
await perms.update(grant.id, {
|
|
81
|
+
const { data: members } = await perms.list(); // { data, meta } verbatim
|
|
82
|
+
const grant = await perms.grant({ user_id: "user_123", can_read: true });
|
|
83
|
+
await perms.update(grant.id, { can_write: true });
|
|
69
84
|
await perms.revoke(grant.id);
|
|
70
85
|
```
|
|
71
86
|
|
|
@@ -73,8 +88,9 @@ await perms.revoke(grant.id);
|
|
|
73
88
|
|
|
74
89
|
| Method | HTTP | Returns |
|
|
75
90
|
| --- | --- | --- |
|
|
76
|
-
| `tables.list()` | `GET /tables` | `Page<
|
|
77
|
-
| `tables.get(idOrName)` | `GET /tables/{id}` | `
|
|
91
|
+
| `tables.list()` | `GET /tables` | `Page<Table>` |
|
|
92
|
+
| `tables.get(idOrName)` | `GET /tables/{id}` | `Table` (with `columns`) |
|
|
93
|
+
| `schema(tableId)` | `GET /tables/{id}` | `Table` (alias of `tables.get`) |
|
|
78
94
|
| `records(t).list(query?)` | `GET /tables/{t}/records` | `Page<Record>` |
|
|
79
95
|
| `records(t).get(id)` | `GET /tables/{t}/records/{id}` | `Record` |
|
|
80
96
|
| `records(t).create(values)` | `POST /tables/{t}/records` | `Record` |
|
|
@@ -82,13 +98,52 @@ await perms.revoke(grant.id);
|
|
|
82
98
|
| `records(t).delete(id)` | `DELETE /tables/{t}/records/{id}` | `void` |
|
|
83
99
|
| `records(t).aggregate(spec)` | `GET /tables/{t}/records/aggregate` | `AggregateResult` |
|
|
84
100
|
| `records(t).permissions(r).list()` | `GET /tables/{t}/records/{r}/permissions` | `Page<RecordPermission>` |
|
|
85
|
-
| `records(t).permissions(r).grant(
|
|
101
|
+
| `records(t).permissions(r).grant(body)` | `POST /tables/{t}/records/{r}/permissions` | `RecordPermission` |
|
|
86
102
|
| `records(t).permissions(r).update(pid, patch)` | `PUT /tables/{t}/records/{r}/permissions/{pid}` | `RecordPermission` |
|
|
87
103
|
| `records(t).permissions(r).revoke(pid)` | `DELETE /tables/{t}/records/{r}/permissions/{pid}` | `void` |
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
104
|
+
|
|
105
|
+
### Query params (snake_case on the wire)
|
|
106
|
+
|
|
107
|
+
| Caller key | Wire param |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| `limit` | `limit` |
|
|
110
|
+
| `offset` | `offset` |
|
|
111
|
+
| `q` | `q` |
|
|
112
|
+
| `filterMode` | `filter_mode` (`and` \| `or`) |
|
|
113
|
+
| `sort` | `sort` (e.g. `-created_at`) |
|
|
114
|
+
| `filter[col]` | `filter[col]=op:value` (inner key is the author's column name, verbatim) |
|
|
115
|
+
| `groupBy` | `group_by` |
|
|
116
|
+
| `sumField` | `sum_field` |
|
|
117
|
+
|
|
118
|
+
## Factory options
|
|
119
|
+
|
|
120
|
+
| Option | Type | Notes |
|
|
121
|
+
| --- | --- | --- |
|
|
122
|
+
| `baseUrl` | `string` | Required. |
|
|
123
|
+
| `getToken` | `() => string \| Promise<string>` | Required. Returns the Authorization value; `Bearer ` prefix added if missing. |
|
|
124
|
+
| `getTenantId` | `() => string \| Promise<string>` | Required. Returns the `x-tenant-id` value. |
|
|
125
|
+
| `getRequestHeaders` | `({ namespace, operation }) => object \| Promise<object>` | Optional. Extra headers merged per request (e.g. scope tokens). `namespace` is one of `tables` / `records` / `permissions`. |
|
|
126
|
+
| `fetchImpl` | `typeof fetch` | Optional. Defaults to `globalThis.fetch`. |
|
|
127
|
+
|
|
128
|
+
## Transport
|
|
129
|
+
|
|
130
|
+
| Concern | Behaviour |
|
|
131
|
+
| --- | --- |
|
|
132
|
+
| Auth header | From `getToken` (host-injected); `Bearer ` prefix normalised. |
|
|
133
|
+
| Tenant header | `x-tenant-id` from `getTenantId` (host-injected). |
|
|
134
|
+
| Retries | Idempotent GETs retried 3× with exponential backoff (200/400/800 ms). |
|
|
135
|
+
| Timeouts | 10 s default, configurable per call via `timeoutMs`. |
|
|
136
|
+
| Error model | Typed `DatastoreError` hierarchy (`NotFoundError`, `ForbiddenError`, `ValidationError`, `RateLimitedError`, `ServerError`). |
|
|
137
|
+
| Platform | Browser **and** React Native (uses `fetch` + `AbortController`). |
|
|
91
138
|
|
|
92
139
|
## Dependencies
|
|
93
140
|
|
|
94
141
|
None. The client uses only platform `fetch` and `AbortController`, both available in modern browsers, Node 18+, and React Native.
|
|
142
|
+
|
|
143
|
+
## Tests
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
node --test src
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Fully self-contained — no `npm install`, no cross-package deps.
|
package/dist/client.js
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
// Datastore client factory.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Datastore client factory (DATA PLANE only).
|
|
2
|
+
//
|
|
3
|
+
// This package is the typed data-plane client: tables (schema), records
|
|
4
|
+
// (CRUD + query + aggregate), and record-level permissions (RLS). It does
|
|
5
|
+
// NOT carry users, groups, files, or payments — those live in sibling
|
|
6
|
+
// packages. The host injects baseUrl, a token provider, a tenant-id
|
|
7
|
+
// provider, an optional per-request header provider (used to attach
|
|
8
|
+
// per-widget scope tokens without this SDK knowing about /widgets/
|
|
9
|
+
// scope-token), and an optional fetch implementation.
|
|
10
|
+
//
|
|
11
|
+
// CRITICAL — snake_case, NO transform. The wire format is snake_case in
|
|
12
|
+
// BOTH directions (REQ-GEN-09) and that is the client contract too. Request
|
|
13
|
+
// bodies are sent snake_case VERBATIM (e.g. { user_id, can_read }). Response
|
|
14
|
+
// objects are returned snake_case VERBATIM (e.g. { id, created_at,
|
|
15
|
+
// table_id }). There is no case-mapping helper anywhere in this package.
|
|
16
|
+
// List endpoints return the { data, meta } envelope verbatim — list methods
|
|
17
|
+
// do not unwrap or remap.
|
|
5
18
|
|
|
6
19
|
import { errorFromResponse, DatastoreError } from "./errors.js";
|
|
7
20
|
import { withRetry } from "./retry.js";
|
|
@@ -13,10 +26,10 @@ function joinUrl(base, path) {
|
|
|
13
26
|
}
|
|
14
27
|
|
|
15
28
|
// Serialise a column-filter map into the backend's bracket form
|
|
16
|
-
// (`filter[col]=op:value`). The column names are author-authored and
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
29
|
+
// (`filter[col]=op:value`). The column names are author-authored and pass
|
|
30
|
+
// through verbatim (the backend exempts the `filter` subtree from its
|
|
31
|
+
// snake_case query normalisation); the `op:value` expression is URL-encoded.
|
|
32
|
+
// Used by both the record list and the aggregate query.
|
|
20
33
|
function appendFilter(parts, filter) {
|
|
21
34
|
if (!filter || typeof filter !== "object") return;
|
|
22
35
|
for (const [col, expr] of Object.entries(filter)) {
|
|
@@ -28,8 +41,11 @@ function appendFilter(parts, filter) {
|
|
|
28
41
|
}
|
|
29
42
|
|
|
30
43
|
// Record-list query string. Mirrors the backend contract
|
|
31
|
-
// (`GET /tables/:id/records`): `limit` + `offset` pagination, free-text
|
|
32
|
-
// `
|
|
44
|
+
// (`GET /tables/:id/records`): `limit` + `offset` pagination, free-text `q`,
|
|
45
|
+
// `filter_mode` (and|or), `sort` (e.g. `-created_at`), and
|
|
46
|
+
// `filter[col]=op:value` conditions. Query keys are snake_case on the wire;
|
|
47
|
+
// the caller passes `filterMode` (a JS method-name convenience) which maps to
|
|
48
|
+
// `filter_mode`.
|
|
33
49
|
function buildQueryString(query) {
|
|
34
50
|
if (!query || typeof query !== "object") return "";
|
|
35
51
|
const parts = [];
|
|
@@ -42,13 +58,17 @@ function buildQueryString(query) {
|
|
|
42
58
|
if (query.filterMode === "or" || query.filterMode === "and") {
|
|
43
59
|
parts.push(`filter_mode=${query.filterMode}`);
|
|
44
60
|
}
|
|
61
|
+
if (query.sort != null && query.sort !== "")
|
|
62
|
+
parts.push(`sort=${encodeURIComponent(query.sort)}`);
|
|
45
63
|
appendFilter(parts, query.filter);
|
|
46
64
|
return parts.length ? `?${parts.join("&")}` : "";
|
|
47
65
|
}
|
|
48
66
|
|
|
49
67
|
// Aggregate query string. Mirrors the backend contract
|
|
50
|
-
// (`GET /tables/:id/records/aggregate`): a single `group_by` column, a
|
|
51
|
-
//
|
|
68
|
+
// (`GET /tables/:id/records/aggregate`): a single `group_by` column, a single
|
|
69
|
+
// `sum_field`, and optional `filter[col]=op:value` conditions. The caller
|
|
70
|
+
// passes `groupBy` / `sumField` (JS-name convenience) which map to the
|
|
71
|
+
// snake_case wire params.
|
|
52
72
|
function buildAggregateQueryString(spec) {
|
|
53
73
|
if (!spec || typeof spec !== "object") return "";
|
|
54
74
|
const parts = [];
|
|
@@ -59,54 +79,19 @@ function buildAggregateQueryString(spec) {
|
|
|
59
79
|
return parts.length ? `?${parts.join("&")}` : "";
|
|
60
80
|
}
|
|
61
81
|
|
|
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
82
|
/**
|
|
99
83
|
* @param {object} opts
|
|
100
84
|
* @param {string} opts.baseUrl
|
|
101
|
-
* @param {() => string | Promise<string>} opts.getToken Returns the Authorization header value (e.g. "Bearer <jwt>").
|
|
85
|
+
* @param {() => string | Promise<string>} opts.getToken Returns the Authorization header value (e.g. "Bearer <jwt>"); the "Bearer " prefix is added if missing.
|
|
102
86
|
* @param {() => string | Promise<string>} opts.getTenantId Returns the x-tenant-id header value.
|
|
87
|
+
* @param {(ctx: { namespace: string, operation: string }) => object | Promise<object>} [opts.getRequestHeaders] Optional per-request extra headers (e.g. { 'X-Widget-Scopes': '...' }).
|
|
103
88
|
* @param {typeof fetch} [opts.fetchImpl] Defaults to globalThis.fetch.
|
|
104
89
|
*/
|
|
105
90
|
export function createDatastoreClient(opts) {
|
|
106
91
|
if (!opts || typeof opts !== "object") {
|
|
107
92
|
throw new TypeError("createDatastoreClient: opts is required");
|
|
108
93
|
}
|
|
109
|
-
const { baseUrl, getToken, getTenantId } = opts;
|
|
94
|
+
const { baseUrl, getToken, getTenantId, getRequestHeaders } = opts;
|
|
110
95
|
if (typeof baseUrl !== "string" || baseUrl.length === 0) {
|
|
111
96
|
throw new TypeError("createDatastoreClient: baseUrl is required");
|
|
112
97
|
}
|
|
@@ -118,6 +103,11 @@ export function createDatastoreClient(opts) {
|
|
|
118
103
|
"createDatastoreClient: getTenantId must be a function",
|
|
119
104
|
);
|
|
120
105
|
}
|
|
106
|
+
if (getRequestHeaders !== undefined && typeof getRequestHeaders !== "function") {
|
|
107
|
+
throw new TypeError(
|
|
108
|
+
"createDatastoreClient: getRequestHeaders must be a function when provided",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
121
111
|
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
122
112
|
if (typeof fetchImpl !== "function") {
|
|
123
113
|
throw new TypeError(
|
|
@@ -125,7 +115,7 @@ export function createDatastoreClient(opts) {
|
|
|
125
115
|
);
|
|
126
116
|
}
|
|
127
117
|
|
|
128
|
-
async function request(method, path, { body, timeoutMs } = {}) {
|
|
118
|
+
async function request(method, path, { body, timeoutMs, namespace, operation } = {}) {
|
|
129
119
|
const url = joinUrl(baseUrl, path);
|
|
130
120
|
const token = await getToken();
|
|
131
121
|
const tenantId = await getTenantId();
|
|
@@ -138,6 +128,15 @@ export function createDatastoreClient(opts) {
|
|
|
138
128
|
if (tenantId) headers["x-tenant-id"] = tenantId;
|
|
139
129
|
if (body !== undefined) headers["content-type"] = "application/json";
|
|
140
130
|
|
|
131
|
+
if (getRequestHeaders) {
|
|
132
|
+
const extra = await getRequestHeaders({ namespace, operation });
|
|
133
|
+
if (extra && typeof extra === "object") {
|
|
134
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
135
|
+
if (v !== undefined && v !== null) headers[k] = v;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
141
140
|
return withRetry({ method, timeoutMs }, async (signal) => {
|
|
142
141
|
let res;
|
|
143
142
|
try {
|
|
@@ -177,28 +176,51 @@ export function createDatastoreClient(opts) {
|
|
|
177
176
|
}
|
|
178
177
|
const enc = encodeURIComponent(table);
|
|
179
178
|
return {
|
|
179
|
+
// GET /tables/{t}/records — returns the { data, meta } envelope verbatim.
|
|
180
180
|
list: (query) =>
|
|
181
|
-
request("GET", `/tables/${enc}/records${buildQueryString(query)}
|
|
181
|
+
request("GET", `/tables/${enc}/records${buildQueryString(query)}`, {
|
|
182
|
+
namespace: "records",
|
|
183
|
+
operation: "list",
|
|
184
|
+
}),
|
|
185
|
+
// GET /tables/{t}/records/{id}
|
|
182
186
|
get: (id) =>
|
|
183
|
-
request("GET", `/tables/${enc}/records/${encodeURIComponent(id)}
|
|
187
|
+
request("GET", `/tables/${enc}/records/${encodeURIComponent(id)}`, {
|
|
188
|
+
namespace: "records",
|
|
189
|
+
operation: "get",
|
|
190
|
+
}),
|
|
191
|
+
// POST /tables/{t}/records — values sent snake_case verbatim.
|
|
184
192
|
create: (values) =>
|
|
185
|
-
request("POST", `/tables/${enc}/records`, {
|
|
193
|
+
request("POST", `/tables/${enc}/records`, {
|
|
194
|
+
body: values,
|
|
195
|
+
namespace: "records",
|
|
196
|
+
operation: "create",
|
|
197
|
+
}),
|
|
198
|
+
// PATCH /tables/{t}/records/{id} — partial update (PATCH, not PUT).
|
|
186
199
|
update: (id, values) =>
|
|
187
200
|
request("PATCH", `/tables/${enc}/records/${encodeURIComponent(id)}`, {
|
|
188
201
|
body: values,
|
|
202
|
+
namespace: "records",
|
|
203
|
+
operation: "update",
|
|
189
204
|
}),
|
|
205
|
+
// DELETE /tables/{t}/records/{id}
|
|
190
206
|
delete: (id) =>
|
|
191
|
-
request("DELETE", `/tables/${enc}/records/${encodeURIComponent(id)}
|
|
207
|
+
request("DELETE", `/tables/${enc}/records/${encodeURIComponent(id)}`, {
|
|
208
|
+
namespace: "records",
|
|
209
|
+
operation: "delete",
|
|
210
|
+
}),
|
|
211
|
+
// GET /tables/{t}/records/aggregate — returns [{ group, count, sum }].
|
|
192
212
|
aggregate: (spec) =>
|
|
193
213
|
request(
|
|
194
214
|
"GET",
|
|
195
215
|
`/tables/${enc}/records/aggregate${buildAggregateQueryString(spec)}`,
|
|
216
|
+
{ namespace: "records", operation: "aggregate" },
|
|
196
217
|
),
|
|
197
218
|
// REQ-ACL-06: row-level permission grants on a single record. A grant
|
|
198
|
-
// to a user OR group with `
|
|
199
|
-
// Chat widget's channel model.
|
|
219
|
+
// to a user OR group with `can_read` is what membership means — see the
|
|
220
|
+
// Chat widget's channel model. Bodies are sent snake_case verbatim and
|
|
221
|
+
// rows are returned snake_case verbatim. Mirrors the backend routes:
|
|
200
222
|
// GET …/permissions list grants ({ data, meta })
|
|
201
|
-
// POST …/permissions grant one (provide
|
|
223
|
+
// POST …/permissions grant one (provide user_id XOR group_id)
|
|
202
224
|
// PUT …/permissions/{permissionId} update one grant's flags
|
|
203
225
|
// DELETE …/permissions/{permissionId} revoke one grant
|
|
204
226
|
permissions: (recordId) => {
|
|
@@ -209,48 +231,49 @@ export function createDatastoreClient(opts) {
|
|
|
209
231
|
}
|
|
210
232
|
const base = `/tables/${enc}/records/${encodeURIComponent(recordId)}/permissions`;
|
|
211
233
|
return {
|
|
212
|
-
list:
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
grant:
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
),
|
|
223
|
-
update:
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
),
|
|
230
|
-
),
|
|
234
|
+
list: () =>
|
|
235
|
+
request("GET", base, {
|
|
236
|
+
namespace: "permissions",
|
|
237
|
+
operation: "list",
|
|
238
|
+
}),
|
|
239
|
+
grant: (body) =>
|
|
240
|
+
request("POST", base, {
|
|
241
|
+
body,
|
|
242
|
+
namespace: "permissions",
|
|
243
|
+
operation: "grant",
|
|
244
|
+
}),
|
|
245
|
+
update: (permissionId, patch) =>
|
|
246
|
+
request("PUT", `${base}/${encodeURIComponent(permissionId)}`, {
|
|
247
|
+
body: patch,
|
|
248
|
+
namespace: "permissions",
|
|
249
|
+
operation: "update",
|
|
250
|
+
}),
|
|
231
251
|
revoke: (permissionId) =>
|
|
232
|
-
request("DELETE", `${base}/${encodeURIComponent(permissionId)}
|
|
252
|
+
request("DELETE", `${base}/${encodeURIComponent(permissionId)}`, {
|
|
253
|
+
namespace: "permissions",
|
|
254
|
+
operation: "revoke",
|
|
255
|
+
}),
|
|
233
256
|
};
|
|
234
257
|
},
|
|
235
258
|
};
|
|
236
259
|
}
|
|
237
260
|
|
|
261
|
+
const tables = {
|
|
262
|
+
// GET /tables — returns the { data, meta } envelope verbatim.
|
|
263
|
+
list: () => request("GET", `/tables`, { namespace: "tables", operation: "list" }),
|
|
264
|
+
// GET /tables/{id} — full table schema { id, name, columns, ... }.
|
|
265
|
+
get: (idOrName) =>
|
|
266
|
+
request("GET", `/tables/${encodeURIComponent(idOrName)}`, {
|
|
267
|
+
namespace: "tables",
|
|
268
|
+
operation: "get",
|
|
269
|
+
}),
|
|
270
|
+
};
|
|
271
|
+
|
|
238
272
|
return {
|
|
239
|
-
tables
|
|
240
|
-
list: () => request("GET", `/tables`),
|
|
241
|
-
get: (idOrName) =>
|
|
242
|
-
request("GET", `/tables/${encodeURIComponent(idOrName)}`),
|
|
243
|
-
},
|
|
273
|
+
tables,
|
|
244
274
|
records: recordsNs,
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
me: () => request("GET", `/auth/app/me`),
|
|
249
|
-
get: (id) => request("GET", `/app/users/${encodeURIComponent(id)}`),
|
|
250
|
-
},
|
|
251
|
-
groups: {
|
|
252
|
-
// The groups the calling principal belongs to.
|
|
253
|
-
listMine: () => request("GET", `/app/groups/mine`),
|
|
254
|
-
},
|
|
275
|
+
// schema(tableId) is an alias of tables.get for the table's column
|
|
276
|
+
// structure — the host calls ctx.datastore.schema(t).
|
|
277
|
+
schema: (tableId) => tables.get(tableId),
|
|
255
278
|
};
|
|
256
279
|
}
|
package/dist/client.test.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
// Wire-contract tests for createDatastoreClient. A capturing fetch
|
|
2
|
-
// implementation records the method + URL each namespace method emits
|
|
3
|
-
// we lock the client to the backend's real REST surface
|
|
4
|
-
//
|
|
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("
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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("
|
|
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 () => {
|
|
159
|
+
test("records.permissions.list returns { data, meta } snake_case verbatim", async () => {
|
|
124
160
|
const cap = {};
|
|
125
|
-
const
|
|
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
|
-
|
|
152
|
-
assert.
|
|
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
|
|
192
|
+
test("records.permissions.grant POSTs the snake_case body verbatim and returns the row verbatim", async () => {
|
|
159
193
|
const cap = {};
|
|
160
|
-
const
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
178
|
-
assert.deepEqual(
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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", {
|
|
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,65 @@ 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
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,27 +1,60 @@
|
|
|
1
1
|
// Hand-written ambient types for @colixsystems/datastore-client.
|
|
2
2
|
// The package is plain ESM JavaScript; this file is shipped for IDE
|
|
3
3
|
// IntelliSense. Keep in sync with src/index.js when adding exports.
|
|
4
|
+
//
|
|
5
|
+
// CONTRACT: snake_case in BOTH directions. Wire fields are snake_case
|
|
6
|
+
// verbatim (`table_id`, `created_at`, `can_read`, …) and the client does NO
|
|
7
|
+
// case transform. The only camelCase here is JS method names and the factory
|
|
8
|
+
// option names. This is the DATA PLANE only — tables, records, aggregates,
|
|
9
|
+
// and record-level permissions. Users / groups / files / payments live in
|
|
10
|
+
// sibling packages.
|
|
11
|
+
|
|
12
|
+
// A column on a virtual table (snake_case wire shape).
|
|
13
|
+
export interface Column {
|
|
14
|
+
id: string;
|
|
15
|
+
table_id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
data_type: string;
|
|
18
|
+
required?: boolean;
|
|
19
|
+
target_table_id?: string | null;
|
|
20
|
+
relation_type?: string | null;
|
|
21
|
+
is_identification_column?: boolean;
|
|
22
|
+
encrypted?: boolean;
|
|
23
|
+
inherit_acl?: boolean;
|
|
24
|
+
default_value?: unknown;
|
|
25
|
+
created_at?: string;
|
|
26
|
+
updated_at?: string;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
}
|
|
4
29
|
|
|
5
|
-
|
|
30
|
+
// The full table schema returned by `tables.get(idOrName)` / `schema(t)`.
|
|
31
|
+
export interface Table {
|
|
6
32
|
id: string;
|
|
33
|
+
tenant_id?: string;
|
|
7
34
|
name: string;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
35
|
+
grant_creator_permissions?: boolean;
|
|
36
|
+
allow_anonymous_create?: boolean;
|
|
37
|
+
html_template?: string | null;
|
|
38
|
+
columns?: Column[];
|
|
39
|
+
created_at?: string;
|
|
40
|
+
updated_at?: string;
|
|
41
|
+
deleted_at?: string | null;
|
|
42
|
+
[key: string]: unknown;
|
|
14
43
|
}
|
|
15
44
|
|
|
45
|
+
// A free-form record. Keys correspond to the table's column `name`s; the
|
|
46
|
+
// host-managed fields are snake_case (`id`, `created_at`, `updated_at`).
|
|
16
47
|
export interface Record_ {
|
|
17
48
|
id: string;
|
|
49
|
+
created_at?: string;
|
|
50
|
+
updated_at?: string;
|
|
18
51
|
[key: string]: unknown;
|
|
19
52
|
}
|
|
20
53
|
export { Record_ as Record };
|
|
21
54
|
|
|
22
55
|
// The uniform list envelope every collection endpoint returns
|
|
23
|
-
// (`{ data, meta }`). `meta.total` is the ACL-aware row
|
|
24
|
-
// with `limit
|
|
56
|
+
// (`{ data, meta }`), returned VERBATIM. `meta.total` is the ACL-aware row
|
|
57
|
+
// count; use it with `limit` / `offset` to page.
|
|
25
58
|
export interface Page<T> {
|
|
26
59
|
data: T[];
|
|
27
60
|
meta: {
|
|
@@ -34,93 +67,78 @@ export interface Page<T> {
|
|
|
34
67
|
// Record-list query. A `filter` maps a column name to an `op:value`
|
|
35
68
|
// expression — e.g. `{ status: "eq:Open", priority: "gte:3" }`. Supported
|
|
36
69
|
// operators: eq, neq, lt, gt, gte, lte, contains, empty, nempty (and the
|
|
37
|
-
// `me` sentinel for USER columns). `filterMode` ANDs conditions
|
|
38
|
-
// default, or ORs them. `q` is a free-text search
|
|
39
|
-
// string/text columns.
|
|
70
|
+
// `me` sentinel for USER / USER_GROUP columns). `filterMode` ANDs conditions
|
|
71
|
+
// by default, or ORs them (sent as `filter_mode`). `q` is a free-text search
|
|
72
|
+
// across the table's string/text columns. `sort` is a column name optionally
|
|
73
|
+
// prefixed with `-` for descending (e.g. `-created_at`). Pagination is
|
|
74
|
+
// `limit` + `offset`.
|
|
40
75
|
export interface Query {
|
|
41
|
-
filter?:
|
|
76
|
+
filter?: { [column: string]: string };
|
|
42
77
|
filterMode?: "and" | "or";
|
|
43
78
|
q?: string;
|
|
79
|
+
sort?: string;
|
|
44
80
|
limit?: number;
|
|
45
81
|
offset?: number;
|
|
46
82
|
}
|
|
47
83
|
|
|
48
84
|
// Aggregate request: group rows by a single column and (optionally) sum a
|
|
49
|
-
// single numeric field, with the same `filter` map the list accepts.
|
|
85
|
+
// single numeric field, with the same `filter` map the list accepts. Sent as
|
|
86
|
+
// `group_by` / `sum_field` on the wire.
|
|
50
87
|
export interface AggregateSpec {
|
|
51
88
|
groupBy?: string;
|
|
52
89
|
sumField?: string;
|
|
53
|
-
filter?:
|
|
90
|
+
filter?: { [column: string]: string };
|
|
54
91
|
}
|
|
55
92
|
|
|
56
93
|
// Aggregate response: one row per group with the row count and the sum of
|
|
57
|
-
// `
|
|
94
|
+
// `sum_field` (0 when no `sum_field` was requested).
|
|
58
95
|
export type AggregateResult = Array<{
|
|
59
96
|
group: string;
|
|
60
97
|
count: number;
|
|
61
98
|
sum: number;
|
|
62
99
|
}>;
|
|
63
100
|
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
name: string | null;
|
|
70
|
-
email?: string | null;
|
|
71
|
-
role?: string;
|
|
72
|
-
groupIds?: string[];
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export interface Group {
|
|
76
|
-
id: string;
|
|
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.
|
|
101
|
+
// A single row-level grant on a record (REQ-ACL-06), snake_case verbatim.
|
|
102
|
+
// Exactly one of `user_id` / `group_id` is set; the four `can_*` flags are
|
|
103
|
+
// the capability bits. `source_kind` / `source_id` are non-null only for
|
|
104
|
+
// grants cascaded from a parent record through an inheriting relation —
|
|
105
|
+
// directly-authored grants have both null.
|
|
88
106
|
export interface RecordPermission {
|
|
89
107
|
id: string;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
108
|
+
table_id: string;
|
|
109
|
+
record_id: string | null;
|
|
110
|
+
user_id: string | null;
|
|
111
|
+
group_id: string | null;
|
|
112
|
+
can_read: boolean;
|
|
113
|
+
can_write: boolean;
|
|
114
|
+
can_delete: boolean;
|
|
115
|
+
can_grant: boolean;
|
|
116
|
+
source_kind: string | null;
|
|
117
|
+
source_id: string | null;
|
|
118
|
+
created_at: string;
|
|
119
|
+
updated_at: string;
|
|
102
120
|
}
|
|
103
121
|
|
|
104
|
-
// Grant body for `permissions(recordId).grant(...)
|
|
105
|
-
// `
|
|
106
|
-
// the rest `false` when omitted.
|
|
122
|
+
// Grant body for `permissions(recordId).grant(...)`, sent snake_case
|
|
123
|
+
// verbatim. Provide exactly ONE of `user_id` or `group_id`. Flags default
|
|
124
|
+
// server-side to `can_read: true` and the rest `false` when omitted.
|
|
107
125
|
export interface RecordPermissionGrant {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
126
|
+
user_id?: string | null;
|
|
127
|
+
group_id?: string | null;
|
|
128
|
+
can_read?: boolean;
|
|
129
|
+
can_write?: boolean;
|
|
130
|
+
can_delete?: boolean;
|
|
131
|
+
can_grant?: boolean;
|
|
114
132
|
}
|
|
115
133
|
|
|
116
|
-
// Flag patch for `permissions(recordId).update(...)
|
|
117
|
-
// capability bits are mutable; the grantee is fixed
|
|
118
|
-
// flags are left unchanged.
|
|
134
|
+
// Flag patch for `permissions(recordId).update(...)`, sent snake_case
|
|
135
|
+
// verbatim. Only the four capability bits are mutable; the grantee is fixed
|
|
136
|
+
// at grant time. Omitted flags are left unchanged.
|
|
119
137
|
export interface RecordPermissionPatch {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
138
|
+
can_read?: boolean;
|
|
139
|
+
can_write?: boolean;
|
|
140
|
+
can_delete?: boolean;
|
|
141
|
+
can_grant?: boolean;
|
|
124
142
|
}
|
|
125
143
|
|
|
126
144
|
export interface RecordPermissionsNamespace {
|
|
@@ -145,23 +163,27 @@ export interface RecordsNamespace {
|
|
|
145
163
|
|
|
146
164
|
export interface DatastoreClient {
|
|
147
165
|
tables: {
|
|
148
|
-
list(): Promise<
|
|
149
|
-
get(idOrName: string): Promise<
|
|
166
|
+
list(): Promise<Page<Table>>;
|
|
167
|
+
get(idOrName: string): Promise<Table>;
|
|
150
168
|
};
|
|
169
|
+
// Alias of tables.get for the table's column structure (the host calls
|
|
170
|
+
// ctx.datastore.schema(t)).
|
|
171
|
+
schema(tableId: string): Promise<Table>;
|
|
151
172
|
records(table: string): RecordsNamespace;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
listMine(): Promise<Group[]>;
|
|
158
|
-
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface RequestHeadersContext {
|
|
176
|
+
namespace: string;
|
|
177
|
+
operation: string;
|
|
159
178
|
}
|
|
160
179
|
|
|
161
180
|
export interface CreateDatastoreClientOptions {
|
|
162
181
|
baseUrl: string;
|
|
163
182
|
getToken: () => string | Promise<string>;
|
|
164
183
|
getTenantId: () => string | Promise<string>;
|
|
184
|
+
getRequestHeaders?: (
|
|
185
|
+
ctx: RequestHeadersContext,
|
|
186
|
+
) => Record<string, string> | Promise<Record<string, string>>;
|
|
165
187
|
fetchImpl?: typeof fetch;
|
|
166
188
|
}
|
|
167
189
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/datastore-client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Typed, scoped client for the AppStudio datastore API
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Typed, scoped data-plane client for the AppStudio datastore API (tables, records, aggregates, record-level permissions). snake_case wire contract, no transform.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"module": "./dist/index.js",
|