@colixsystems/datastore-client 0.3.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 +96 -27
- package/dist/client.js +131 -34
- package/dist/client.test.js +225 -26
- package/dist/index.d.ts +115 -39
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,25 +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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
| Timeouts | Browser default | 10s default, configurable per call |
|
|
21
|
-
| Error model | Raw axios error | Typed `DatastoreError` hierarchy |
|
|
22
|
-
| Platform | Browser only | Browser **and** React Native (uses `fetch`) |
|
|
29
|
+
The wire format is snake_case in **both** directions and that is the client contract too. The SDK does **no** case mapping:
|
|
30
|
+
|
|
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.
|
|
23
34
|
|
|
24
35
|
## Public API
|
|
25
36
|
|
|
@@ -36,45 +47,103 @@ import {
|
|
|
36
47
|
|
|
37
48
|
const client = createDatastoreClient({
|
|
38
49
|
baseUrl: "https://api.appstudio.io",
|
|
39
|
-
getToken: () => "Bearer ...",
|
|
50
|
+
getToken: () => "Bearer ...", // "Bearer " prefix added if missing
|
|
40
51
|
getTenantId: () => "tenant_abc",
|
|
52
|
+
getRequestHeaders: ({ namespace, operation }) => ({ "X-Widget-Scopes": "..." }), // optional
|
|
41
53
|
// fetchImpl defaults to globalThis.fetch
|
|
42
54
|
});
|
|
43
55
|
|
|
44
|
-
|
|
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
|
|
45
60
|
|
|
46
|
-
//
|
|
47
|
-
// contains, empty, nempty). Pagination is limit + offset.
|
|
48
|
-
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
|
|
49
64
|
.records("orders")
|
|
50
|
-
.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");
|
|
51
71
|
|
|
52
72
|
// Aggregate: group by one column, optionally sum one numeric field.
|
|
53
73
|
const byStatus = await client
|
|
54
74
|
.records("orders")
|
|
55
|
-
.aggregate({ groupBy: "status", sumField: "
|
|
75
|
+
.aggregate({ groupBy: "status", sumField: "amount_cents" });
|
|
56
76
|
// → [{ group: "PAID", count: 12, sum: 4200 }, ...]
|
|
57
77
|
|
|
58
|
-
|
|
59
|
-
|
|
78
|
+
// Row-level permissions (REQ-ACL-06): a grant to a user OR group with
|
|
79
|
+
// can_read is what membership means. Provide exactly one of user_id/group_id.
|
|
80
|
+
const perms = client.records("channels").permissions(channelId);
|
|
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 });
|
|
84
|
+
await perms.revoke(grant.id);
|
|
60
85
|
```
|
|
61
86
|
|
|
62
87
|
## Surface
|
|
63
88
|
|
|
64
89
|
| Method | HTTP | Returns |
|
|
65
90
|
| --- | --- | --- |
|
|
66
|
-
| `tables.list()` | `GET /tables` | `Page<
|
|
67
|
-
| `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`) |
|
|
68
94
|
| `records(t).list(query?)` | `GET /tables/{t}/records` | `Page<Record>` |
|
|
69
95
|
| `records(t).get(id)` | `GET /tables/{t}/records/{id}` | `Record` |
|
|
70
96
|
| `records(t).create(values)` | `POST /tables/{t}/records` | `Record` |
|
|
71
97
|
| `records(t).update(id, values)` | `PATCH /tables/{t}/records/{id}` | `Record` |
|
|
72
98
|
| `records(t).delete(id)` | `DELETE /tables/{t}/records/{id}` | `void` |
|
|
73
99
|
| `records(t).aggregate(spec)` | `GET /tables/{t}/records/aggregate` | `AggregateResult` |
|
|
74
|
-
| `
|
|
75
|
-
| `
|
|
76
|
-
| `
|
|
100
|
+
| `records(t).permissions(r).list()` | `GET /tables/{t}/records/{r}/permissions` | `Page<RecordPermission>` |
|
|
101
|
+
| `records(t).permissions(r).grant(body)` | `POST /tables/{t}/records/{r}/permissions` | `RecordPermission` |
|
|
102
|
+
| `records(t).permissions(r).update(pid, patch)` | `PUT /tables/{t}/records/{r}/permissions/{pid}` | `RecordPermission` |
|
|
103
|
+
| `records(t).permissions(r).revoke(pid)` | `DELETE /tables/{t}/records/{r}/permissions/{pid}` | `void` |
|
|
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`). |
|
|
77
138
|
|
|
78
139
|
## Dependencies
|
|
79
140
|
|
|
80
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 = [];
|
|
@@ -62,15 +82,16 @@ function buildAggregateQueryString(spec) {
|
|
|
62
82
|
/**
|
|
63
83
|
* @param {object} opts
|
|
64
84
|
* @param {string} opts.baseUrl
|
|
65
|
-
* @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.
|
|
66
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': '...' }).
|
|
67
88
|
* @param {typeof fetch} [opts.fetchImpl] Defaults to globalThis.fetch.
|
|
68
89
|
*/
|
|
69
90
|
export function createDatastoreClient(opts) {
|
|
70
91
|
if (!opts || typeof opts !== "object") {
|
|
71
92
|
throw new TypeError("createDatastoreClient: opts is required");
|
|
72
93
|
}
|
|
73
|
-
const { baseUrl, getToken, getTenantId } = opts;
|
|
94
|
+
const { baseUrl, getToken, getTenantId, getRequestHeaders } = opts;
|
|
74
95
|
if (typeof baseUrl !== "string" || baseUrl.length === 0) {
|
|
75
96
|
throw new TypeError("createDatastoreClient: baseUrl is required");
|
|
76
97
|
}
|
|
@@ -82,6 +103,11 @@ export function createDatastoreClient(opts) {
|
|
|
82
103
|
"createDatastoreClient: getTenantId must be a function",
|
|
83
104
|
);
|
|
84
105
|
}
|
|
106
|
+
if (getRequestHeaders !== undefined && typeof getRequestHeaders !== "function") {
|
|
107
|
+
throw new TypeError(
|
|
108
|
+
"createDatastoreClient: getRequestHeaders must be a function when provided",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
85
111
|
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
86
112
|
if (typeof fetchImpl !== "function") {
|
|
87
113
|
throw new TypeError(
|
|
@@ -89,7 +115,7 @@ export function createDatastoreClient(opts) {
|
|
|
89
115
|
);
|
|
90
116
|
}
|
|
91
117
|
|
|
92
|
-
async function request(method, path, { body, timeoutMs } = {}) {
|
|
118
|
+
async function request(method, path, { body, timeoutMs, namespace, operation } = {}) {
|
|
93
119
|
const url = joinUrl(baseUrl, path);
|
|
94
120
|
const token = await getToken();
|
|
95
121
|
const tenantId = await getTenantId();
|
|
@@ -102,6 +128,15 @@ export function createDatastoreClient(opts) {
|
|
|
102
128
|
if (tenantId) headers["x-tenant-id"] = tenantId;
|
|
103
129
|
if (body !== undefined) headers["content-type"] = "application/json";
|
|
104
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
|
+
|
|
105
140
|
return withRetry({ method, timeoutMs }, async (signal) => {
|
|
106
141
|
let res;
|
|
107
142
|
try {
|
|
@@ -141,42 +176,104 @@ export function createDatastoreClient(opts) {
|
|
|
141
176
|
}
|
|
142
177
|
const enc = encodeURIComponent(table);
|
|
143
178
|
return {
|
|
179
|
+
// GET /tables/{t}/records — returns the { data, meta } envelope verbatim.
|
|
144
180
|
list: (query) =>
|
|
145
|
-
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}
|
|
146
186
|
get: (id) =>
|
|
147
|
-
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.
|
|
148
192
|
create: (values) =>
|
|
149
|
-
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).
|
|
150
199
|
update: (id, values) =>
|
|
151
200
|
request("PATCH", `/tables/${enc}/records/${encodeURIComponent(id)}`, {
|
|
152
201
|
body: values,
|
|
202
|
+
namespace: "records",
|
|
203
|
+
operation: "update",
|
|
153
204
|
}),
|
|
205
|
+
// DELETE /tables/{t}/records/{id}
|
|
154
206
|
delete: (id) =>
|
|
155
|
-
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 }].
|
|
156
212
|
aggregate: (spec) =>
|
|
157
213
|
request(
|
|
158
214
|
"GET",
|
|
159
215
|
`/tables/${enc}/records/aggregate${buildAggregateQueryString(spec)}`,
|
|
216
|
+
{ namespace: "records", operation: "aggregate" },
|
|
160
217
|
),
|
|
218
|
+
// REQ-ACL-06: row-level permission grants on a single record. A grant
|
|
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:
|
|
222
|
+
// GET …/permissions list grants ({ data, meta })
|
|
223
|
+
// POST …/permissions grant one (provide user_id XOR group_id)
|
|
224
|
+
// PUT …/permissions/{permissionId} update one grant's flags
|
|
225
|
+
// DELETE …/permissions/{permissionId} revoke one grant
|
|
226
|
+
permissions: (recordId) => {
|
|
227
|
+
if (typeof recordId !== "string" || recordId.length === 0) {
|
|
228
|
+
throw new TypeError(
|
|
229
|
+
"records(table).permissions(recordId): recordId must be a non-empty string",
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
const base = `/tables/${enc}/records/${encodeURIComponent(recordId)}/permissions`;
|
|
233
|
+
return {
|
|
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
|
+
}),
|
|
251
|
+
revoke: (permissionId) =>
|
|
252
|
+
request("DELETE", `${base}/${encodeURIComponent(permissionId)}`, {
|
|
253
|
+
namespace: "permissions",
|
|
254
|
+
operation: "revoke",
|
|
255
|
+
}),
|
|
256
|
+
};
|
|
257
|
+
},
|
|
161
258
|
};
|
|
162
259
|
}
|
|
163
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
|
+
|
|
164
272
|
return {
|
|
165
|
-
tables
|
|
166
|
-
list: () => request("GET", `/tables`),
|
|
167
|
-
get: (idOrName) =>
|
|
168
|
-
request("GET", `/tables/${encodeURIComponent(idOrName)}`),
|
|
169
|
-
},
|
|
273
|
+
tables,
|
|
170
274
|
records: recordsNs,
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
me: () => request("GET", `/auth/app/me`),
|
|
175
|
-
get: (id) => request("GET", `/app/users/${encodeURIComponent(id)}`),
|
|
176
|
-
},
|
|
177
|
-
groups: {
|
|
178
|
-
// The groups the calling principal belongs to.
|
|
179
|
-
listMine: () => request("GET", `/app/groups/mine`),
|
|
180
|
-
},
|
|
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),
|
|
181
278
|
};
|
|
182
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,46 +141,180 @@ 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("
|
|
159
|
+
test("records.permissions.list returns { data, meta } snake_case verbatim", async () => {
|
|
93
160
|
const cap = {};
|
|
94
|
-
const
|
|
95
|
-
|
|
161
|
+
const body = {
|
|
162
|
+
data: [
|
|
163
|
+
{
|
|
164
|
+
id: "p1",
|
|
165
|
+
table_id: "Channels",
|
|
166
|
+
record_id: "c1",
|
|
167
|
+
user_id: "u1",
|
|
168
|
+
group_id: null,
|
|
169
|
+
can_read: true,
|
|
170
|
+
can_write: false,
|
|
171
|
+
can_delete: false,
|
|
172
|
+
can_grant: false,
|
|
173
|
+
source_kind: null,
|
|
174
|
+
source_id: null,
|
|
175
|
+
created_at: "2026-01-01T00:00:00Z",
|
|
176
|
+
updated_at: "2026-01-02T00:00:00Z",
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
meta: { total: 1, limit: 1, offset: 0 },
|
|
180
|
+
};
|
|
181
|
+
const client = makeClient(cap, body);
|
|
182
|
+
const res = await client.records("Channels").permissions("c1").list();
|
|
96
183
|
assert.equal(cap.method, "GET");
|
|
97
|
-
assert.equal(
|
|
184
|
+
assert.equal(
|
|
185
|
+
new URL(cap.url).pathname,
|
|
186
|
+
"/api/v1/tables/Channels/records/c1/permissions",
|
|
187
|
+
);
|
|
188
|
+
// No transform — the row is returned snake_case verbatim.
|
|
189
|
+
assert.deepEqual(res, body);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("records.permissions.grant POSTs the snake_case body verbatim and returns the row verbatim", async () => {
|
|
193
|
+
const cap = {};
|
|
194
|
+
const row = {
|
|
195
|
+
id: "p2",
|
|
196
|
+
table_id: "Channels",
|
|
197
|
+
record_id: "c1",
|
|
198
|
+
user_id: "u2",
|
|
199
|
+
group_id: null,
|
|
200
|
+
can_read: true,
|
|
201
|
+
can_write: true,
|
|
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
|
|
211
|
+
.records("Channels")
|
|
212
|
+
.permissions("c1")
|
|
213
|
+
.grant({ user_id: "u2", can_read: true, can_write: true });
|
|
214
|
+
assert.equal(cap.method, "POST");
|
|
215
|
+
assert.equal(
|
|
216
|
+
new URL(cap.url).pathname,
|
|
217
|
+
"/api/v1/tables/Channels/records/c1/permissions",
|
|
218
|
+
);
|
|
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);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("records.permissions.update PUTs flags by permissionId, verbatim", async () => {
|
|
229
|
+
const cap = {};
|
|
230
|
+
const client = makeClient(cap, { id: "p2", can_delete: true });
|
|
231
|
+
await client
|
|
232
|
+
.records("Channels")
|
|
233
|
+
.permissions("c1")
|
|
234
|
+
.update("p2", { can_delete: true });
|
|
235
|
+
assert.equal(cap.method, "PUT");
|
|
236
|
+
assert.equal(
|
|
237
|
+
new URL(cap.url).pathname,
|
|
238
|
+
"/api/v1/tables/Channels/records/c1/permissions/p2",
|
|
239
|
+
);
|
|
240
|
+
assert.deepEqual(JSON.parse(cap.body), { can_delete: true });
|
|
98
241
|
});
|
|
99
242
|
|
|
100
|
-
test("
|
|
243
|
+
test("records.permissions.revoke DELETEs by permissionId", async () => {
|
|
101
244
|
const cap = {};
|
|
102
|
-
const client = makeClient(cap, {
|
|
103
|
-
await client.
|
|
104
|
-
assert.equal(
|
|
245
|
+
const client = makeClient(cap, {});
|
|
246
|
+
await client.records("Channels").permissions("c1").revoke("p2");
|
|
247
|
+
assert.equal(cap.method, "DELETE");
|
|
248
|
+
assert.equal(
|
|
249
|
+
new URL(cap.url).pathname,
|
|
250
|
+
"/api/v1/tables/Channels/records/c1/permissions/p2",
|
|
251
|
+
);
|
|
105
252
|
});
|
|
106
253
|
|
|
107
|
-
test("
|
|
254
|
+
test("records.permissions requires a non-empty recordId", () => {
|
|
108
255
|
const cap = {};
|
|
109
256
|
const client = makeClient(cap);
|
|
110
|
-
|
|
111
|
-
assert.equal(new URL(cap.url).pathname, "/api/v1/app/groups/mine");
|
|
257
|
+
assert.throws(() => client.records("Channels").permissions(""), TypeError);
|
|
112
258
|
});
|
|
113
259
|
|
|
114
|
-
test("
|
|
260
|
+
test("records(table) requires a non-empty table", () => {
|
|
115
261
|
const cap = {};
|
|
116
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
|
+
});
|
|
117
271
|
await client.tables.list();
|
|
118
|
-
assert.equal(
|
|
119
|
-
|
|
120
|
-
assert.equal(
|
|
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
|
+
});
|
|
121
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.
|
|
4
11
|
|
|
5
|
-
|
|
12
|
+
// A column on a virtual table (snake_case wire shape).
|
|
13
|
+
export interface Column {
|
|
6
14
|
id: string;
|
|
15
|
+
table_id: string;
|
|
7
16
|
name: string;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
+
}
|
|
29
|
+
|
|
30
|
+
// The full table schema returned by `tables.get(idOrName)` / `schema(t)`.
|
|
31
|
+
export interface Table {
|
|
32
|
+
id: string;
|
|
33
|
+
tenant_id?: string;
|
|
34
|
+
name: string;
|
|
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,50 +67,88 @@ 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
|
-
|
|
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.
|
|
106
|
+
export interface RecordPermission {
|
|
68
107
|
id: string;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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;
|
|
73
120
|
}
|
|
74
121
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
|
125
|
+
export interface RecordPermissionGrant {
|
|
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;
|
|
132
|
+
}
|
|
133
|
+
|
|
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.
|
|
137
|
+
export interface RecordPermissionPatch {
|
|
138
|
+
can_read?: boolean;
|
|
139
|
+
can_write?: boolean;
|
|
140
|
+
can_delete?: boolean;
|
|
141
|
+
can_grant?: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface RecordPermissionsNamespace {
|
|
145
|
+
list(): Promise<Page<RecordPermission>>;
|
|
146
|
+
grant(grant: RecordPermissionGrant): Promise<RecordPermission>;
|
|
147
|
+
update(
|
|
148
|
+
permissionId: string,
|
|
149
|
+
patch: RecordPermissionPatch,
|
|
150
|
+
): Promise<RecordPermission>;
|
|
151
|
+
revoke(permissionId: string): Promise<void>;
|
|
81
152
|
}
|
|
82
153
|
|
|
83
154
|
export interface RecordsNamespace {
|
|
@@ -87,27 +158,32 @@ export interface RecordsNamespace {
|
|
|
87
158
|
update(id: string, values: Partial<Record_>): Promise<Record_>;
|
|
88
159
|
delete(id: string): Promise<void>;
|
|
89
160
|
aggregate(spec: AggregateSpec): Promise<AggregateResult>;
|
|
161
|
+
permissions(recordId: string): RecordPermissionsNamespace;
|
|
90
162
|
}
|
|
91
163
|
|
|
92
164
|
export interface DatastoreClient {
|
|
93
165
|
tables: {
|
|
94
|
-
list(): Promise<
|
|
95
|
-
get(idOrName: string): Promise<
|
|
166
|
+
list(): Promise<Page<Table>>;
|
|
167
|
+
get(idOrName: string): Promise<Table>;
|
|
96
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>;
|
|
97
172
|
records(table: string): RecordsNamespace;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
listMine(): Promise<Group[]>;
|
|
104
|
-
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface RequestHeadersContext {
|
|
176
|
+
namespace: string;
|
|
177
|
+
operation: string;
|
|
105
178
|
}
|
|
106
179
|
|
|
107
180
|
export interface CreateDatastoreClientOptions {
|
|
108
181
|
baseUrl: string;
|
|
109
182
|
getToken: () => string | Promise<string>;
|
|
110
183
|
getTenantId: () => string | Promise<string>;
|
|
184
|
+
getRequestHeaders?: (
|
|
185
|
+
ctx: RequestHeadersContext,
|
|
186
|
+
) => Record<string, string> | Promise<Record<string, string>>;
|
|
111
187
|
fetchImpl?: typeof fetch;
|
|
112
188
|
}
|
|
113
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",
|