@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 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. Widgets receive an instance through the injected `WidgetContext.datastore` — they never instantiate this client themselves.
3
+ Typed, scoped **data-plane** client for the [AppStudio](https://github.com/appstudio) datastore API. It covers exactly three things:
4
4
 
5
- See the design reference: [`docs/architecture/widget-marketplace.md`](../../docs/architecture/widget-marketplace.md), specifically section 3.2.
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.4.0` — pre-publish. Not yet published to npm.
17
+ `v0.5.0` — pre-publish. Not yet published to npm.
10
18
 
11
- > **0.4.0 (additive):** added the `records(t).permissions(recordId)` namespace `list()`, `grant()`, `update(permissionId, patch)`, `revoke(permissionId)` covering REQ-ACL-06 row-level grants. The client presents the camelCase shape (`userId`, `canRead`, …) and translates to the snake_case wire contract in both directions.
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
- > **0.3.0 (breaking):** the client now matches the AppStudio backend's real REST contract. `records().list()` takes `{ limit, offset, q, filterMode, filter }` (the old `cursor` / JSON `filter` and `sort` are gone) and returns the `{ data, meta }` envelope. `records().aggregate({ groupBy, sumField, filter })` is a `GET` returning `[{ group, count, sum }]`. `users.me()` resolves the current principal via `/auth/app/me`. Filter values are `op:value` expressions.
27
+ ## Contract: snake_case, no transform
14
28
 
15
- ## Differences from `frontend/src/api/client.js`
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
- | Concern | `frontend/src/api/client.js` | `@colixsystems/datastore-client` |
18
- | --- | --- | --- |
19
- | Auth header | Reads `useAuthStore` directly | Token injected by host |
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
- const tables = await client.tables.list();
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
- // Filter values are `op:value` expressions (eq, neq, lt, gt, gte, lte,
49
- // contains, empty, nempty). Pagination is limit + offset.
50
- const { data: orders, meta } = await client
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: "total" });
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
- // `canRead` is what membership means. Provide exactly one of userId/groupId.
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({ userId: "user_123", canRead: true });
68
- await perms.update(grant.id, { canWrite: true });
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<TableMeta>` |
77
- | `tables.get(idOrName)` | `GET /tables/{id}` | `TableMeta` |
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(grant)` | `POST /tables/{t}/records/{r}/permissions` | `RecordPermission` |
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
- | `users.me()` | `GET /auth/app/me` | `AppUser` |
89
- | `users.get(id)` | `GET /app/users/{id}` | `AppUser` |
90
- | `groups.listMine()` | `GET /app/groups/mine` | `Page<Group>` |
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
- // The host injects baseUrl, a token provider, a tenant-id provider, and an
3
- // optional fetch implementation. Widgets never touch this directly they
4
- // receive a built client through WidgetContext.datastore.
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
- // pass through verbatim (the backend exempts the `filter` subtree from
18
- // its snake_case query normalisation); the `op:value` expression is URL-
19
- // encoded. Used by both the record list and the aggregate query.
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
- // `q`, `filter_mode` (and|or), and `filter[col]=op:value` conditions.
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
- // single `sum_field`, and optional `filter[col]=op:value` conditions.
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`, { body: values }),
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 `canRead` is what membership means — see the
199
- // Chat widget's channel model. Mirrors the backend routes:
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 userId XOR groupId)
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: async () => {
213
- const res = await request("GET", base);
214
- const rows = Array.isArray(res?.data) ? res.data : [];
215
- return { data: rows.map(deserializePermission), meta: res?.meta };
216
- },
217
- grant: async (grant) =>
218
- deserializePermission(
219
- await request("POST", base, {
220
- body: serializePermissionBody(grant),
221
- }),
222
- ),
223
- update: async (permissionId, patch) =>
224
- deserializePermission(
225
- await request(
226
- "PUT",
227
- `${base}/${encodeURIComponent(permissionId)}`,
228
- { body: serializePermissionBody(patch) },
229
- ),
230
- ),
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
- users: {
246
- // The current principal. For an app-user JWT this is the logged-in
247
- // user; for an INTEGRATION API key it is the bound service account.
248
- me: () => request("GET", `/auth/app/me`),
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
  }
@@ -1,17 +1,18 @@
1
1
  // Wire-contract tests for createDatastoreClient. A capturing fetch
2
- // implementation records the method + URL each namespace method emits so
3
- // we lock the client to the backend's real REST surface (REQ-OPS-DOC-EXT
4
- // reconciliation). These run under `node --test`.
2
+ // implementation records the method + URL + body each namespace method emits
3
+ // so we lock the client to the backend's real snake_case REST surface
4
+ // (REQ-GEN-09). These run under `node --test` with NO npm install.
5
5
 
6
6
  import { test } from "node:test";
7
7
  import assert from "node:assert/strict";
8
8
  import { createDatastoreClient } from "./client.js";
9
9
 
10
- function makeClient(captured, responseBody = { data: [], meta: {} }) {
10
+ function makeClient(captured, responseBody = { data: [], meta: {} }, extra = {}) {
11
11
  const fetchImpl = async (url, init) => {
12
12
  captured.url = url;
13
13
  captured.method = init.method;
14
14
  captured.body = init.body;
15
+ captured.headers = init.headers;
15
16
  return {
16
17
  ok: true,
17
18
  status: 200,
@@ -23,10 +24,63 @@ function makeClient(captured, responseBody = { data: [], meta: {} }) {
23
24
  getToken: () => "Bearer as_testkey",
24
25
  getTenantId: () => "tenant-1",
25
26
  fetchImpl,
27
+ ...extra,
26
28
  });
27
29
  }
28
30
 
29
- test("records.list serialises limit/offset/q/filter_mode/filter[col]", async () => {
31
+ test("factory validates opts", () => {
32
+ assert.throws(() => createDatastoreClient(), TypeError);
33
+ assert.throws(
34
+ () => createDatastoreClient({ getToken: () => "", getTenantId: () => "" }),
35
+ TypeError,
36
+ );
37
+ assert.throws(
38
+ () => createDatastoreClient({ baseUrl: "x", getTenantId: () => "" }),
39
+ TypeError,
40
+ );
41
+ assert.throws(
42
+ () => createDatastoreClient({ baseUrl: "x", getToken: () => "" }),
43
+ TypeError,
44
+ );
45
+ assert.throws(
46
+ () =>
47
+ createDatastoreClient({
48
+ baseUrl: "x",
49
+ getToken: () => "",
50
+ getTenantId: () => "",
51
+ getRequestHeaders: "nope",
52
+ }),
53
+ TypeError,
54
+ );
55
+ });
56
+
57
+ test("tables.list / tables.get paths return the envelope/schema verbatim", async () => {
58
+ const cap = {};
59
+ const client = makeClient(cap, { data: [{ id: "t1", name: "Tasks" }], meta: { total: 1, limit: 50, offset: 0 } });
60
+ const list = await client.tables.list();
61
+ assert.equal(cap.method, "GET");
62
+ assert.equal(new URL(cap.url).pathname, "/api/v1/tables");
63
+ assert.deepEqual(list, { data: [{ id: "t1", name: "Tasks" }], meta: { total: 1, limit: 50, offset: 0 } });
64
+
65
+ const tableSchema = { id: "t1", name: "Tasks", columns: [{ id: "c1", table_id: "t1", name: "title", data_type: "STRING" }] };
66
+ const cap2 = {};
67
+ const client2 = makeClient(cap2, tableSchema);
68
+ const got = await client2.tables.get("Tasks");
69
+ assert.equal(new URL(cap2.url).pathname, "/api/v1/tables/Tasks");
70
+ assert.deepEqual(got, tableSchema);
71
+ });
72
+
73
+ test("schema(t) is an alias of tables.get", async () => {
74
+ const cap = {};
75
+ const tableSchema = { id: "t1", name: "Tasks", columns: [] };
76
+ const client = makeClient(cap, tableSchema);
77
+ const got = await client.schema("t1");
78
+ assert.equal(cap.method, "GET");
79
+ assert.equal(new URL(cap.url).pathname, "/api/v1/tables/t1");
80
+ assert.deepEqual(got, tableSchema);
81
+ });
82
+
83
+ test("records.list serialises limit/offset/q/filter_mode/sort/filter[col]", async () => {
30
84
  const cap = {};
31
85
  const client = makeClient(cap);
32
86
  await client.records("Tasks").list({
@@ -34,6 +88,7 @@ test("records.list serialises limit/offset/q/filter_mode/filter[col]", async ()
34
88
  offset: 50,
35
89
  q: "urgent",
36
90
  filterMode: "or",
91
+ sort: "-created_at",
37
92
  filter: { status: "eq:Open", priority: "gte:3" },
38
93
  });
39
94
  assert.equal(cap.method, "GET");
@@ -43,17 +98,26 @@ test("records.list serialises limit/offset/q/filter_mode/filter[col]", async ()
43
98
  assert.equal(u.searchParams.get("offset"), "50");
44
99
  assert.equal(u.searchParams.get("q"), "urgent");
45
100
  assert.equal(u.searchParams.get("filter_mode"), "or");
101
+ assert.equal(u.searchParams.get("sort"), "-created_at");
46
102
  assert.equal(u.searchParams.get("filter[status]"), "eq:Open");
47
103
  assert.equal(u.searchParams.get("filter[priority]"), "gte:3");
48
- // The legacy `cursor` / JSON `filter=` forms must be gone.
49
- assert.equal(u.searchParams.get("cursor"), null);
50
- assert.equal(u.searchParams.get("filter"), null);
104
+ });
105
+
106
+ test("records.list returns the { data, meta } envelope verbatim (no unwrap)", async () => {
107
+ const cap = {};
108
+ const body = {
109
+ data: [{ id: "r1", title: "x", created_at: "2026-01-01T00:00:00Z" }],
110
+ meta: { total: 1, limit: 50, offset: 0 },
111
+ };
112
+ const client = makeClient(cap, body);
113
+ const res = await client.records("Tasks").list();
114
+ assert.deepEqual(res, body);
51
115
  });
52
116
 
53
117
  test("records.aggregate is a GET on /records/aggregate with group_by/sum_field", async () => {
54
118
  const cap = {};
55
119
  const client = makeClient(cap, []);
56
- await client.records("Sales").aggregate({
120
+ const res = await client.records("Sales").aggregate({
57
121
  groupBy: "region",
58
122
  sumField: "amount",
59
123
  filter: { status: "eq:Closed" },
@@ -65,9 +129,10 @@ test("records.aggregate is a GET on /records/aggregate with group_by/sum_field",
65
129
  assert.equal(u.searchParams.get("group_by"), "region");
66
130
  assert.equal(u.searchParams.get("sum_field"), "amount");
67
131
  assert.equal(u.searchParams.get("filter[status]"), "eq:Closed");
132
+ assert.deepEqual(res, []);
68
133
  });
69
134
 
70
- test("record CRUD verbs + paths", async () => {
135
+ test("record CRUD verbs + paths; create/update send the body verbatim", async () => {
71
136
  const cap = {};
72
137
  const client = makeClient(cap, { id: "r1" });
73
138
  const ns = client.records("Tasks");
@@ -76,53 +141,24 @@ test("record CRUD verbs + paths", async () => {
76
141
  assert.equal(cap.method, "GET");
77
142
  assert.equal(new URL(cap.url).pathname, "/api/v1/tables/Tasks/records/r1");
78
143
 
79
- await ns.create({ title: "x" });
144
+ await ns.create({ title: "x", due_date: "2026-01-01" });
80
145
  assert.equal(cap.method, "POST");
81
146
  assert.equal(new URL(cap.url).pathname, "/api/v1/tables/Tasks/records");
147
+ assert.deepEqual(JSON.parse(cap.body), { title: "x", due_date: "2026-01-01" });
82
148
 
83
149
  await ns.update("r1", { title: "y" });
84
150
  assert.equal(cap.method, "PATCH");
85
151
  assert.equal(new URL(cap.url).pathname, "/api/v1/tables/Tasks/records/r1");
152
+ assert.deepEqual(JSON.parse(cap.body), { title: "y" });
86
153
 
87
154
  await ns.delete("r1");
88
155
  assert.equal(cap.method, "DELETE");
89
156
  assert.equal(new URL(cap.url).pathname, "/api/v1/tables/Tasks/records/r1");
90
157
  });
91
158
 
92
- test("users.me targets /auth/app/me (not /app/users/me)", async () => {
93
- const cap = {};
94
- const client = makeClient(cap, { id: "u1", name: "A", email: "a@b.c" });
95
- await client.users.me();
96
- assert.equal(cap.method, "GET");
97
- assert.equal(new URL(cap.url).pathname, "/api/v1/auth/app/me");
98
- });
99
-
100
- test("users.get targets /app/users/{id}", async () => {
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 client = makeClient(cap, {
161
+ const body = {
126
162
  data: [
127
163
  {
128
164
  id: "p1",
@@ -141,54 +177,61 @@ test("records.permissions.list maps {data,meta} rows to camelCase", async () =>
141
177
  },
142
178
  ],
143
179
  meta: { total: 1, limit: 1, offset: 0 },
144
- });
180
+ };
181
+ const client = makeClient(cap, body);
145
182
  const res = await client.records("Channels").permissions("c1").list();
146
183
  assert.equal(cap.method, "GET");
147
184
  assert.equal(
148
185
  new URL(cap.url).pathname,
149
186
  "/api/v1/tables/Channels/records/c1/permissions",
150
187
  );
151
- assert.deepEqual(res.meta, { total: 1, limit: 1, offset: 0 });
152
- assert.equal(res.data[0].tableId, "Channels");
153
- assert.equal(res.data[0].userId, "u1");
154
- assert.equal(res.data[0].canRead, true);
155
- assert.equal(res.data[0].canWrite, false);
188
+ // No transform the row is returned snake_case verbatim.
189
+ assert.deepEqual(res, body);
156
190
  });
157
191
 
158
- test("records.permissions.grant POSTs a snake_case body", async () => {
192
+ test("records.permissions.grant POSTs the snake_case body verbatim and returns the row verbatim", async () => {
159
193
  const cap = {};
160
- const client = makeClient(cap, {
194
+ const row = {
161
195
  id: "p2",
162
196
  table_id: "Channels",
163
197
  record_id: "c1",
164
198
  user_id: "u2",
199
+ group_id: null,
165
200
  can_read: true,
166
201
  can_write: true,
167
- });
168
- const row = await client
202
+ can_delete: false,
203
+ can_grant: false,
204
+ source_kind: null,
205
+ source_id: null,
206
+ created_at: "2026-01-01T00:00:00Z",
207
+ updated_at: "2026-01-01T00:00:00Z",
208
+ };
209
+ const client = makeClient(cap, row);
210
+ const out = await client
169
211
  .records("Channels")
170
212
  .permissions("c1")
171
- .grant({ userId: "u2", canRead: true, canWrite: true });
213
+ .grant({ user_id: "u2", can_read: true, can_write: true });
172
214
  assert.equal(cap.method, "POST");
173
215
  assert.equal(
174
216
  new URL(cap.url).pathname,
175
217
  "/api/v1/tables/Channels/records/c1/permissions",
176
218
  );
177
- const sent = JSON.parse(cap.body);
178
- assert.deepEqual(sent, { user_id: "u2", can_read: true, can_write: true });
179
- // Response is mapped back to camelCase.
180
- assert.equal(row.id, "p2");
181
- assert.equal(row.userId, "u2");
182
- assert.equal(row.canWrite, true);
219
+ // Body is forwarded verbatim — no camel->snake mapping.
220
+ assert.deepEqual(JSON.parse(cap.body), {
221
+ user_id: "u2",
222
+ can_read: true,
223
+ can_write: true,
224
+ });
225
+ assert.deepEqual(out, row);
183
226
  });
184
227
 
185
- test("records.permissions.update PUTs flags by permissionId", async () => {
228
+ test("records.permissions.update PUTs flags by permissionId, verbatim", async () => {
186
229
  const cap = {};
187
230
  const client = makeClient(cap, { id: "p2", can_delete: true });
188
231
  await client
189
232
  .records("Channels")
190
233
  .permissions("c1")
191
- .update("p2", { canDelete: true });
234
+ .update("p2", { can_delete: true });
192
235
  assert.equal(cap.method, "PUT");
193
236
  assert.equal(
194
237
  new URL(cap.url).pathname,
@@ -213,3 +256,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
- export interface TableMeta {
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
- displayName?: string;
9
- columns: Array<{
10
- name: string;
11
- type: string;
12
- required?: boolean;
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 count; use it
24
- // with `limit`/`offset` to page.
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 by
38
- // default, or ORs them. `q` is a free-text search across the table's
39
- // string/text columns. Pagination is `limit` + `offset`.
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?: Record<string, string>;
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?: Record<string, string>;
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
- // `sumField` (0 when no `sumField` was requested).
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
- // The principal returned by `users.me()` (`GET /auth/app/me`). `email` is
65
- // present for the logged-in user; the directory projection from
66
- // `users.get(id)` returns `{ id, name, role }` for non-Studio callers.
67
- export interface AppUser {
68
- id: string;
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
- tableId: string;
91
- recordId: string | null;
92
- userId: string | null;
93
- groupId: string | null;
94
- canRead: boolean;
95
- canWrite: boolean;
96
- canDelete: boolean;
97
- canGrant: boolean;
98
- sourceKind: string | null;
99
- sourceId: string | null;
100
- createdAt: string;
101
- updatedAt: string;
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(...)`. Provide exactly ONE of
105
- // `userId` or `groupId`. Flags default server-side to `canRead: true` and
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
- userId?: string | null;
109
- groupId?: string | null;
110
- canRead?: boolean;
111
- canWrite?: boolean;
112
- canDelete?: boolean;
113
- canGrant?: boolean;
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(...)`. Only the four
117
- // capability bits are mutable; the grantee is fixed at grant time. Omitted
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
- canRead?: boolean;
121
- canWrite?: boolean;
122
- canDelete?: boolean;
123
- canGrant?: boolean;
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<TableMeta[]>;
149
- get(idOrName: string): Promise<TableMeta>;
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
- users: {
153
- me(): Promise<AppUser>;
154
- get(id: string): Promise<AppUser>;
155
- };
156
- groups: {
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.0",
4
- "description": "Typed, scoped client for the AppStudio datastore API. Used by widgets through the injected WidgetContext.",
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",