@colixsystems/datastore-client 0.4.0 → 0.6.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,39 @@
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
+ - **realtime** — subscribe to a table's live change stream (REQ-RT-07) via `records(tableId).subscribe({ onCreated, onUpdated, onDeleted, onStatus })`, returning an unsubscribe function.
9
+
10
+ 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 })`.
11
+
12
+ > **Two surfaces, one package.** This client serves two callers:
13
+ > 1. **External / server-side integrations** instantiate it directly with an API key (`getToken` returns the key, `getTenantId` the tenant) and call the methods below.
14
+ > 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
15
 
7
16
  ## Status
8
17
 
9
- `v0.4.0` — pre-publish. Not yet published to npm.
18
+ `v0.6.0` — pre-publish. Not yet published to npm.
10
19
 
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.
20
+ > **0.6.0 (additive):** added `records(tableId).subscribe({ onCreated, onUpdated, onDeleted, onStatus }, { fallbackAfterMs? })` (REQ-RT-07) — a WebSocket subscription to the table's realtime change stream at `<baseUrl>/datastore/ws`, returning a synchronous unsubscribe function. Server-gated by the same read ACL as REST. New optional `webSocketImpl` factory option (defaults to `globalThis.WebSocket`, present in browsers and React Native); when no impl is available `onStatus` reports `"fallback"` so callers poll. No existing method changed.
12
21
 
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.
22
+ > **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**.
23
+ > - 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, … }`).
24
+ > - `records().list()` returns the `{ data, meta }` envelope verbatim (no unwrap). `tables.list()` likewise.
25
+ > - Added `schema(tableId)` as an alias of `tables.get` for the column structure (the host calls `ctx.datastore.schema(t)`).
26
+ > - 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.
27
+ > - `record list` now accepts a `sort` param (e.g. `-created_at`).
28
+ > - **Removed** the `users` / `groups` namespaces — they belong in sibling packages.
14
29
 
15
- ## Differences from `frontend/src/api/client.js`
30
+ ## Contract: snake_case, no transform
16
31
 
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`) |
32
+ The wire format is snake_case in **both** directions and that is the client contract too. The SDK does **no** case mapping:
33
+
34
+ - Request bodies are sent **snake_case verbatim** — you pass `{ user_id, can_read }`, not `{ userId, canRead }`.
35
+ - Response objects are returned **snake_case verbatim** you read `row.created_at`, `row.table_id`, `perm.can_read`.
36
+ - The only camelCase is JS method names (`filterMode`, `groupBy`, `sumField`) and factory option names.
25
37
 
26
38
  ## Public API
27
39
 
@@ -38,34 +50,40 @@ import {
38
50
 
39
51
  const client = createDatastoreClient({
40
52
  baseUrl: "https://api.appstudio.io",
41
- getToken: () => "Bearer ...",
53
+ getToken: () => "Bearer ...", // "Bearer " prefix added if missing
42
54
  getTenantId: () => "tenant_abc",
55
+ getRequestHeaders: ({ namespace, operation }) => ({ "X-Widget-Scopes": "..." }), // optional
43
56
  // fetchImpl defaults to globalThis.fetch
44
57
  });
45
58
 
46
- const tables = await client.tables.list();
59
+ // Tables (schema)
60
+ const { data: tables, meta } = await client.tables.list(); // { data, meta } verbatim
61
+ const table = await client.tables.get("Tasks"); // { id, name, columns: [...] }
62
+ const sameTable = await client.schema("Tasks"); // alias of tables.get
47
63
 
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
64
+ // Records — filter values are `op:value` expressions (eq, neq, lt, gt, gte,
65
+ // lte, contains, empty, nempty). Pagination is limit + offset.
66
+ const { data: orders } = await client
51
67
  .records("orders")
52
- .list({ filter: { status: "eq:PAID" }, limit: 50, offset: 0 });
68
+ .list({ filter: { status: "eq:PAID" }, sort: "-created_at", limit: 50, offset: 0 });
69
+
70
+ const rec = await client.records("orders").get("r1");
71
+ await client.records("orders").create({ status: "PAID", amount_cents: 1200 });
72
+ await client.records("orders").update("r1", { status: "REFUNDED" }); // PATCH
73
+ await client.records("orders").delete("r1");
53
74
 
54
75
  // Aggregate: group by one column, optionally sum one numeric field.
55
76
  const byStatus = await client
56
77
  .records("orders")
57
- .aggregate({ groupBy: "status", sumField: "total" });
78
+ .aggregate({ groupBy: "status", sumField: "amount_cents" });
58
79
  // → [{ group: "PAID", count: 12, sum: 4200 }, ...]
59
80
 
60
- const me = await client.users.me(); // GET /auth/app/me
61
- const myGroups = await client.groups.listMine(); // { data, meta }
62
-
63
81
  // 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.
82
+ // can_read is what membership means. Provide exactly one of user_id/group_id.
65
83
  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 });
84
+ const { data: members } = await perms.list(); // { data, meta } verbatim
85
+ const grant = await perms.grant({ user_id: "user_123", can_read: true });
86
+ await perms.update(grant.id, { can_write: true });
69
87
  await perms.revoke(grant.id);
70
88
  ```
71
89
 
@@ -73,8 +91,9 @@ await perms.revoke(grant.id);
73
91
 
74
92
  | Method | HTTP | Returns |
75
93
  | --- | --- | --- |
76
- | `tables.list()` | `GET /tables` | `Page<TableMeta>` |
77
- | `tables.get(idOrName)` | `GET /tables/{id}` | `TableMeta` |
94
+ | `tables.list()` | `GET /tables` | `Page<Table>` |
95
+ | `tables.get(idOrName)` | `GET /tables/{id}` | `Table` (with `columns`) |
96
+ | `schema(tableId)` | `GET /tables/{id}` | `Table` (alias of `tables.get`) |
78
97
  | `records(t).list(query?)` | `GET /tables/{t}/records` | `Page<Record>` |
79
98
  | `records(t).get(id)` | `GET /tables/{t}/records/{id}` | `Record` |
80
99
  | `records(t).create(values)` | `POST /tables/{t}/records` | `Record` |
@@ -82,13 +101,54 @@ await perms.revoke(grant.id);
82
101
  | `records(t).delete(id)` | `DELETE /tables/{t}/records/{id}` | `void` |
83
102
  | `records(t).aggregate(spec)` | `GET /tables/{t}/records/aggregate` | `AggregateResult` |
84
103
  | `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` |
104
+ | `records(t).permissions(r).grant(body)` | `POST /tables/{t}/records/{r}/permissions` | `RecordPermission` |
86
105
  | `records(t).permissions(r).update(pid, patch)` | `PUT /tables/{t}/records/{r}/permissions/{pid}` | `RecordPermission` |
87
106
  | `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>` |
107
+ | `records(t).subscribe(handlers, opts?)` | `WS <baseUrl>/datastore/ws` (`{type:"subscribe",tableId}`) | `() => void` (unsubscribe) |
108
+
109
+ ### Query params (snake_case on the wire)
110
+
111
+ | Caller key | Wire param |
112
+ | --- | --- |
113
+ | `limit` | `limit` |
114
+ | `offset` | `offset` |
115
+ | `q` | `q` |
116
+ | `filterMode` | `filter_mode` (`and` \| `or`) |
117
+ | `sort` | `sort` (e.g. `-created_at`) |
118
+ | `filter[col]` | `filter[col]=op:value` (inner key is the author's column name, verbatim) |
119
+ | `groupBy` | `group_by` |
120
+ | `sumField` | `sum_field` |
121
+
122
+ ## Factory options
123
+
124
+ | Option | Type | Notes |
125
+ | --- | --- | --- |
126
+ | `baseUrl` | `string` | Required. |
127
+ | `getToken` | `() => string \| Promise<string>` | Required. Returns the Authorization value; `Bearer ` prefix added if missing. |
128
+ | `getTenantId` | `() => string \| Promise<string>` | Required. Returns the `x-tenant-id` value. |
129
+ | `getRequestHeaders` | `({ namespace, operation }) => object \| Promise<object>` | Optional. Extra headers merged per request (e.g. scope tokens). `namespace` is one of `tables` / `records` / `permissions`. |
130
+ | `fetchImpl` | `typeof fetch` | Optional. Defaults to `globalThis.fetch`. |
131
+ | `webSocketImpl` | `typeof WebSocket` | Optional. Used by `records(t).subscribe(...)`. Defaults to `globalThis.WebSocket` (browser + React Native). When absent, `subscribe` reports `"fallback"` and opens no socket. |
132
+
133
+ ## Transport
134
+
135
+ | Concern | Behaviour |
136
+ | --- | --- |
137
+ | Auth header | From `getToken` (host-injected); `Bearer ` prefix normalised. |
138
+ | Tenant header | `x-tenant-id` from `getTenantId` (host-injected). |
139
+ | Retries | Idempotent GETs retried 3× with exponential backoff (200/400/800 ms). |
140
+ | Timeouts | 10 s default, configurable per call via `timeoutMs`. |
141
+ | Error model | Typed `DatastoreError` hierarchy (`NotFoundError`, `ForbiddenError`, `ValidationError`, `RateLimitedError`, `ServerError`). |
142
+ | Platform | Browser **and** React Native (uses `fetch` + `AbortController`). |
91
143
 
92
144
  ## Dependencies
93
145
 
94
146
  None. The client uses only platform `fetch` and `AbortController`, both available in modern browsers, Node 18+, and React Native.
147
+
148
+ ## Tests
149
+
150
+ ```
151
+ node --test src
152
+ ```
153
+
154
+ Fully self-contained — no `npm install`, no cross-package deps.