@colixsystems/datastore-client 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AppStudio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # @colixsystems/datastore-client
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.
4
+
5
+ See the design reference: [`docs/architecture/widget-marketplace.md`](../../docs/architecture/widget-marketplace.md), specifically section 3.2.
6
+
7
+ ## Status
8
+
9
+ `v0.1.0` — pre-publish. Not yet published to npm.
10
+
11
+ ## Differences from `frontend/src/api/client.js`
12
+
13
+ | Concern | `frontend/src/api/client.js` | `@colixsystems/datastore-client` |
14
+ | --- | --- | --- |
15
+ | Auth header | Reads `useAuthStore` directly | Token injected by host |
16
+ | Tenant header | Pulled from store | Injected by host; widget cannot override |
17
+ | Retries | None | Idempotent GETs retried 3x with exponential backoff |
18
+ | Timeouts | Browser default | 10s default, configurable per call |
19
+ | Error model | Raw axios error | Typed `DatastoreError` hierarchy |
20
+ | Platform | Browser only | Browser **and** React Native (uses `fetch`) |
21
+
22
+ ## Public API
23
+
24
+ ```js
25
+ import {
26
+ createDatastoreClient,
27
+ DatastoreError,
28
+ NotFoundError,
29
+ ForbiddenError,
30
+ ValidationError,
31
+ RateLimitedError,
32
+ ServerError,
33
+ } from "@colixsystems/datastore-client";
34
+
35
+ const client = createDatastoreClient({
36
+ baseUrl: "https://api.appstudio.io",
37
+ getToken: () => "Bearer ...",
38
+ getTenantId: () => "tenant_abc",
39
+ // fetchImpl defaults to globalThis.fetch
40
+ });
41
+
42
+ const tables = await client.tables.list();
43
+ const orders = await client.records("orders").list({ filter: { status: "PAID" } });
44
+ ```
45
+
46
+ ## Dependencies
47
+
48
+ None. The client uses only platform `fetch` and `AbortController`, both available in modern browsers, Node 18+, and React Native.
package/dist/client.js ADDED
@@ -0,0 +1,133 @@
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.
5
+
6
+ import { errorFromResponse, DatastoreError } from "./errors.js";
7
+ import { withRetry } from "./retry.js";
8
+
9
+ function joinUrl(base, path) {
10
+ const b = base.endsWith("/") ? base.slice(0, -1) : base;
11
+ const p = path.startsWith("/") ? path : `/${path}`;
12
+ return `${b}${p}`;
13
+ }
14
+
15
+ function buildQueryString(query) {
16
+ if (!query || typeof query !== "object") return "";
17
+ const parts = [];
18
+ if (query.limit != null) parts.push(`limit=${encodeURIComponent(query.limit)}`);
19
+ if (query.cursor) parts.push(`cursor=${encodeURIComponent(query.cursor)}`);
20
+ if (query.sort && Array.isArray(query.sort)) {
21
+ const sortStr = query.sort
22
+ .map((s) => `${s.dir === "desc" ? "-" : ""}${s.field}`)
23
+ .join(",");
24
+ if (sortStr) parts.push(`sort=${encodeURIComponent(sortStr)}`);
25
+ }
26
+ if (query.filter && typeof query.filter === "object") {
27
+ parts.push(`filter=${encodeURIComponent(JSON.stringify(query.filter))}`);
28
+ }
29
+ return parts.length ? `?${parts.join("&")}` : "";
30
+ }
31
+
32
+ /**
33
+ * @param {object} opts
34
+ * @param {string} opts.baseUrl
35
+ * @param {() => string | Promise<string>} opts.getToken Returns the Authorization header value (e.g. "Bearer <jwt>").
36
+ * @param {() => string | Promise<string>} opts.getTenantId Returns the x-tenant-id header value.
37
+ * @param {typeof fetch} [opts.fetchImpl] Defaults to globalThis.fetch.
38
+ */
39
+ export function createDatastoreClient(opts) {
40
+ if (!opts || typeof opts !== "object") {
41
+ throw new TypeError("createDatastoreClient: opts is required");
42
+ }
43
+ const { baseUrl, getToken, getTenantId } = opts;
44
+ if (typeof baseUrl !== "string" || baseUrl.length === 0) {
45
+ throw new TypeError("createDatastoreClient: baseUrl is required");
46
+ }
47
+ if (typeof getToken !== "function") {
48
+ throw new TypeError("createDatastoreClient: getToken must be a function");
49
+ }
50
+ if (typeof getTenantId !== "function") {
51
+ throw new TypeError("createDatastoreClient: getTenantId must be a function");
52
+ }
53
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
54
+ if (typeof fetchImpl !== "function") {
55
+ throw new TypeError(
56
+ "createDatastoreClient: no fetch available. Pass fetchImpl or run in an environment with global fetch."
57
+ );
58
+ }
59
+
60
+ async function request(method, path, { body, timeoutMs } = {}) {
61
+ const url = joinUrl(baseUrl, path);
62
+ const token = await getToken();
63
+ const tenantId = await getTenantId();
64
+
65
+ const headers = { accept: "application/json" };
66
+ if (token) headers.authorization = token.startsWith("Bearer ") ? token : `Bearer ${token}`;
67
+ if (tenantId) headers["x-tenant-id"] = tenantId;
68
+ if (body !== undefined) headers["content-type"] = "application/json";
69
+
70
+ return withRetry({ method, timeoutMs }, async (signal) => {
71
+ let res;
72
+ try {
73
+ res = await fetchImpl(url, {
74
+ method,
75
+ headers,
76
+ body: body !== undefined ? JSON.stringify(body) : undefined,
77
+ signal,
78
+ });
79
+ } catch (err) {
80
+ // Network or abort failure — let withRetry decide if retryable.
81
+ throw new DatastoreError(`Network failure: ${err.message}`, {
82
+ code: "NETWORK",
83
+ status: 0,
84
+ });
85
+ }
86
+
87
+ const text = await res.text();
88
+ let parsed = null;
89
+ if (text.length > 0) {
90
+ try {
91
+ parsed = JSON.parse(text);
92
+ } catch {
93
+ parsed = { raw: text };
94
+ }
95
+ }
96
+ if (!res.ok) {
97
+ throw errorFromResponse(res.status, parsed);
98
+ }
99
+ return parsed;
100
+ });
101
+ }
102
+
103
+ function recordsNs(table) {
104
+ if (typeof table !== "string" || table.length === 0) {
105
+ throw new TypeError("records(table): table must be a non-empty string");
106
+ }
107
+ const enc = encodeURIComponent(table);
108
+ return {
109
+ list: (query) => request("GET", `/tables/${enc}/records${buildQueryString(query)}`),
110
+ get: (id) => request("GET", `/tables/${enc}/records/${encodeURIComponent(id)}`),
111
+ create: (values) => request("POST", `/tables/${enc}/records`, { body: values }),
112
+ update: (id, values) =>
113
+ request("PATCH", `/tables/${enc}/records/${encodeURIComponent(id)}`, { body: values }),
114
+ delete: (id) => request("DELETE", `/tables/${enc}/records/${encodeURIComponent(id)}`),
115
+ aggregate: (spec) => request("POST", `/tables/${enc}/aggregate`, { body: spec }),
116
+ };
117
+ }
118
+
119
+ return {
120
+ tables: {
121
+ list: () => request("GET", `/tables`),
122
+ get: (idOrName) => request("GET", `/tables/${encodeURIComponent(idOrName)}`),
123
+ },
124
+ records: recordsNs,
125
+ users: {
126
+ me: () => request("GET", `/app/users/me`),
127
+ get: (id) => request("GET", `/app/users/${encodeURIComponent(id)}`),
128
+ },
129
+ groups: {
130
+ listMine: () => request("GET", `/app/groups/mine`),
131
+ },
132
+ };
133
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,69 @@
1
+ // Typed error hierarchy for the datastore client.
2
+ // Each subclass carries .code, .status, and .details so callers can branch
3
+ // without parsing strings.
4
+
5
+ export class DatastoreError extends Error {
6
+ constructor(message, { code = "DATASTORE_ERROR", status = 0, details = null } = {}) {
7
+ super(message);
8
+ this.name = "DatastoreError";
9
+ this.code = code;
10
+ this.status = status;
11
+ this.details = details;
12
+ }
13
+ }
14
+
15
+ export class NotFoundError extends DatastoreError {
16
+ constructor(message = "Resource not found", details = null) {
17
+ super(message, { code: "NOT_FOUND", status: 404, details });
18
+ this.name = "NotFoundError";
19
+ }
20
+ }
21
+
22
+ export class ForbiddenError extends DatastoreError {
23
+ constructor(message = "Forbidden", details = null) {
24
+ super(message, { code: "FORBIDDEN", status: 403, details });
25
+ this.name = "ForbiddenError";
26
+ }
27
+ }
28
+
29
+ export class ValidationError extends DatastoreError {
30
+ constructor(message = "Validation failed", details = null) {
31
+ super(message, { code: "VALIDATION", status: 400, details });
32
+ this.name = "ValidationError";
33
+ }
34
+ }
35
+
36
+ export class RateLimitedError extends DatastoreError {
37
+ constructor(message = "Rate limited", details = null) {
38
+ super(message, { code: "RATE_LIMITED", status: 429, details });
39
+ this.name = "RateLimitedError";
40
+ }
41
+ }
42
+
43
+ export class ServerError extends DatastoreError {
44
+ constructor(message = "Server error", status = 500, details = null) {
45
+ super(message, { code: "SERVER_ERROR", status, details });
46
+ this.name = "ServerError";
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Map an HTTP response status + body to the appropriate DatastoreError subclass.
52
+ */
53
+ export function errorFromResponse(status, body) {
54
+ const details = body && typeof body === "object" ? body : null;
55
+ const message = details?.error?.message || details?.message || `HTTP ${status}`;
56
+ switch (status) {
57
+ case 400:
58
+ return new ValidationError(message, details);
59
+ case 403:
60
+ return new ForbiddenError(message, details);
61
+ case 404:
62
+ return new NotFoundError(message, details);
63
+ case 429:
64
+ return new RateLimitedError(message, details);
65
+ default:
66
+ if (status >= 500) return new ServerError(message, status, details);
67
+ return new DatastoreError(message, { code: "UNKNOWN", status, details });
68
+ }
69
+ }
@@ -0,0 +1,116 @@
1
+ // Hand-written ambient types for @colixsystems/datastore-client.
2
+ // The package is plain ESM JavaScript; this file is shipped for IDE
3
+ // IntelliSense. Keep in sync with src/index.js when adding exports.
4
+
5
+ export interface TableMeta {
6
+ id: string;
7
+ name: string;
8
+ displayName?: string;
9
+ columns: Array<{
10
+ name: string;
11
+ type: string;
12
+ required?: boolean;
13
+ }>;
14
+ }
15
+
16
+ export interface Record_ {
17
+ id: string;
18
+ [key: string]: unknown;
19
+ }
20
+ export { Record_ as Record };
21
+
22
+ export interface Page<T> {
23
+ data: T[];
24
+ nextCursor?: string | null;
25
+ }
26
+
27
+ export interface Query {
28
+ filter?: Record<string, unknown>;
29
+ sort?: Array<{ field: string; dir: "asc" | "desc" }>;
30
+ limit?: number;
31
+ cursor?: string;
32
+ }
33
+
34
+ export interface AggregateSpec {
35
+ groupBy?: string[];
36
+ metrics: Array<{
37
+ field?: string;
38
+ op: "count" | "sum" | "avg" | "min" | "max";
39
+ alias?: string;
40
+ }>;
41
+ filter?: Record<string, unknown>;
42
+ }
43
+
44
+ export interface AggregateResult {
45
+ rows: Array<Record<string, unknown>>;
46
+ }
47
+
48
+ export interface AppUser {
49
+ id: string;
50
+ email: string | null;
51
+ displayName: string | null;
52
+ roles: string[];
53
+ groupIds: string[];
54
+ }
55
+
56
+ export interface Group {
57
+ id: string;
58
+ name: string;
59
+ }
60
+
61
+ export interface RecordsNamespace {
62
+ list(query?: Query): Promise<Page<Record_>>;
63
+ get(id: string): Promise<Record_>;
64
+ create(values: Partial<Record_>): Promise<Record_>;
65
+ update(id: string, values: Partial<Record_>): Promise<Record_>;
66
+ delete(id: string): Promise<void>;
67
+ aggregate(spec: AggregateSpec): Promise<AggregateResult>;
68
+ }
69
+
70
+ export interface DatastoreClient {
71
+ tables: {
72
+ list(): Promise<TableMeta[]>;
73
+ get(idOrName: string): Promise<TableMeta>;
74
+ };
75
+ records(table: string): RecordsNamespace;
76
+ users: {
77
+ me(): Promise<AppUser>;
78
+ get(id: string): Promise<AppUser>;
79
+ };
80
+ groups: {
81
+ listMine(): Promise<Group[]>;
82
+ };
83
+ }
84
+
85
+ export interface CreateDatastoreClientOptions {
86
+ baseUrl: string;
87
+ getToken: () => string | Promise<string>;
88
+ getTenantId: () => string | Promise<string>;
89
+ fetchImpl?: typeof fetch;
90
+ }
91
+
92
+ export function createDatastoreClient(
93
+ opts: CreateDatastoreClientOptions
94
+ ): DatastoreClient;
95
+
96
+ export class DatastoreError extends Error {
97
+ code: string;
98
+ status: number;
99
+ details: unknown;
100
+ constructor(
101
+ message: string,
102
+ init?: { code?: string; status?: number; details?: unknown }
103
+ );
104
+ }
105
+ export class NotFoundError extends DatastoreError {}
106
+ export class ForbiddenError extends DatastoreError {}
107
+ export class ValidationError extends DatastoreError {}
108
+ export class RateLimitedError extends DatastoreError {}
109
+ export class ServerError extends DatastoreError {}
110
+
111
+ export function errorFromResponse(status: number, body: unknown): DatastoreError;
112
+
113
+ export function withRetry<T>(
114
+ opts: { method: string; timeoutMs?: number },
115
+ attempt: (signal: AbortSignal) => Promise<T>
116
+ ): Promise<T>;
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ // Public entry for @colixsystems/datastore-client.
2
+
3
+ export { createDatastoreClient } from "./client.js";
4
+ export {
5
+ DatastoreError,
6
+ NotFoundError,
7
+ ForbiddenError,
8
+ ValidationError,
9
+ RateLimitedError,
10
+ ServerError,
11
+ errorFromResponse,
12
+ } from "./errors.js";
13
+ export { withRetry } from "./retry.js";
package/dist/retry.js ADDED
@@ -0,0 +1,55 @@
1
+ // Small retry helper.
2
+ // - Idempotent verbs (GET) retried up to 3x with exponential backoff: 200ms, 400ms, 800ms.
3
+ // - Non-idempotent verbs (POST, PUT, PATCH, DELETE) are not retried.
4
+ // - 10s default per-call timeout via AbortController.
5
+ // - 429 with Retry-After honoured; 5xx retried; 4xx (other than 429) not retried.
6
+
7
+ import { RateLimitedError, ServerError } from "./errors.js";
8
+
9
+ const IDEMPOTENT = new Set(["GET", "HEAD"]);
10
+ const DEFAULT_TIMEOUT_MS = 10_000;
11
+ const BACKOFF_MS = [200, 400, 800];
12
+
13
+ function sleep(ms) {
14
+ return new Promise((resolve) => setTimeout(resolve, ms));
15
+ }
16
+
17
+ function isRetryable(err) {
18
+ if (err instanceof RateLimitedError) return true;
19
+ if (err instanceof ServerError) return true;
20
+ // AbortError or network failure
21
+ if (err && (err.name === "AbortError" || err.name === "TypeError")) return true;
22
+ return false;
23
+ }
24
+
25
+ /**
26
+ * Run `attempt` with retry semantics. `attempt` is invoked with an AbortSignal
27
+ * and is expected to return a Promise. It must reject with a DatastoreError
28
+ * subclass on transport / HTTP failures.
29
+ *
30
+ * @param {{ method: string, timeoutMs?: number }} opts
31
+ * @param {(signal: AbortSignal) => Promise<any>} attempt
32
+ */
33
+ export async function withRetry(opts, attempt) {
34
+ const method = (opts.method || "GET").toUpperCase();
35
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
36
+ const maxAttempts = IDEMPOTENT.has(method) ? BACKOFF_MS.length + 1 : 1;
37
+
38
+ let lastErr;
39
+ for (let i = 0; i < maxAttempts; i++) {
40
+ const controller = new AbortController();
41
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
42
+ try {
43
+ const result = await attempt(controller.signal);
44
+ clearTimeout(timer);
45
+ return result;
46
+ } catch (err) {
47
+ clearTimeout(timer);
48
+ lastErr = err;
49
+ if (i === maxAttempts - 1) break;
50
+ if (!isRetryable(err)) break;
51
+ await sleep(BACKOFF_MS[i] ?? BACKOFF_MS[BACKOFF_MS.length - 1]);
52
+ }
53
+ }
54
+ throw lastErr;
55
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@colixsystems/datastore-client",
3
+ "version": "0.1.0",
4
+ "description": "Typed, scoped client for the AppStudio datastore API. Used by widgets through the injected WidgetContext.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "scripts": {
22
+ "build": "node scripts/build.js",
23
+ "test": "node --test src"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "registry": "https://registry.npmjs.org/"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/colixwestin/appstudio.git",
35
+ "directory": "packages/datastore-client"
36
+ },
37
+ "keywords": [
38
+ "appstudio",
39
+ "datastore",
40
+ "client"
41
+ ],
42
+ "license": "MIT"
43
+ }