@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 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. 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.3.0` — pre-publish. Not yet published to npm.
17
+ `v0.5.0` — pre-publish. Not yet published to npm.
10
18
 
11
- > **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.
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
- ## Differences from `frontend/src/api/client.js`
27
+ ## Contract: snake_case, no transform
14
28
 
15
- | Concern | `frontend/src/api/client.js` | `@colixsystems/datastore-client` |
16
- | --- | --- | --- |
17
- | Auth header | Reads `useAuthStore` directly | Token injected by host |
18
- | Tenant header | Pulled from store | Injected by host; widget cannot override |
19
- | Retries | None | Idempotent GETs retried 3x with exponential backoff |
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
- 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
45
60
 
46
- // Filter values are `op:value` expressions (eq, neq, lt, gt, gte, lte,
47
- // contains, empty, nempty). Pagination is limit + offset.
48
- 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
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: "total" });
75
+ .aggregate({ groupBy: "status", sumField: "amount_cents" });
56
76
  // → [{ group: "PAID", count: 12, sum: 4200 }, ...]
57
77
 
58
- const me = await client.users.me(); // GET /auth/app/me
59
- const myGroups = await client.groups.listMine(); // { data, meta }
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<TableMeta>` |
67
- | `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`) |
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
- | `users.me()` | `GET /auth/app/me` | `AppUser` |
75
- | `users.get(id)` | `GET /app/users/{id}` | `AppUser` |
76
- | `groups.listMine()` | `GET /app/groups/mine` | `Page<Group>` |
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
- // 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 = [];
@@ -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`, { 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).
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
- users: {
172
- // The current principal. For an app-user JWT this is the logged-in
173
- // user; for an INTEGRATION API key it is the bound service account.
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
  }
@@ -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,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("users.me targets /auth/app/me (not /app/users/me)", async () => {
159
+ test("records.permissions.list returns { data, meta } snake_case verbatim", async () => {
93
160
  const cap = {};
94
- const client = makeClient(cap, { id: "u1", name: "A", email: "a@b.c" });
95
- await client.users.me();
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(new URL(cap.url).pathname, "/api/v1/auth/app/me");
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("users.get targets /app/users/{id}", async () => {
243
+ test("records.permissions.revoke DELETEs by permissionId", async () => {
101
244
  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");
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("groups.listMine targets /app/groups/mine", async () => {
254
+ test("records.permissions requires a non-empty recordId", () => {
108
255
  const cap = {};
109
256
  const client = makeClient(cap);
110
- await client.groups.listMine();
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("tables.list / tables.get paths", async () => {
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(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");
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
- export interface TableMeta {
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
- displayName?: string;
9
- columns: Array<{
10
- name: string;
11
- type: string;
12
- required?: boolean;
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 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,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 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 {
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
- name: string | null;
70
- email?: string | null;
71
- role?: string;
72
- groupIds?: 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;
73
120
  }
74
121
 
75
- export interface Group {
76
- id: string;
77
- name: string;
78
- tenantId?: string;
79
- createdAt?: string;
80
- updatedAt?: string;
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<TableMeta[]>;
95
- get(idOrName: string): Promise<TableMeta>;
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
- users: {
99
- me(): Promise<AppUser>;
100
- get(id: string): Promise<AppUser>;
101
- };
102
- groups: {
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.3.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",