@kroombase/client 1.0.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 ADDED
@@ -0,0 +1,94 @@
1
+ # @kroombase/client
2
+
3
+ Typed JavaScript and TypeScript client for **KroomBase** — a hosted PostgreSQL backend. It talks
4
+ to the REST API, so anything that can make an HTTP request can use the same backend; this package
5
+ is the ergonomic path for JS/TS.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @kroombase/client
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```ts
16
+ import { KroomBase } from "@kroombase/client";
17
+
18
+ const db = new KroomBase({ apiKey: "kb_your_api_key" });
19
+
20
+ const rows = await db.from("orders").select(["id", "total"]).where("status", "=", "paid").limit(10);
21
+ const [created] = await db.insert("orders", { customer_id: 1, total: 42 });
22
+ await db.update("orders", created.id, { status: "shipped" });
23
+ await db.delete("orders", created.id);
24
+ ```
25
+
26
+ ## Configuration
27
+
28
+ ```ts
29
+ new KroomBase({
30
+ apiKey: "kb_...", // data access — no raw SQL
31
+ // token: "eyJ...", // session token — also enables sql()
32
+ url: "https://kroombase.kroombox.com", // optional, this is the default
33
+ fetch: customFetch, // optional, e.g. for tests or a proxy
34
+ timeoutMs: 30000, // optional, 0 disables the timeout
35
+ });
36
+ ```
37
+
38
+ One of `apiKey` / `token` is required. The constructor throws if neither is given, rather than
39
+ failing later with a 401.
40
+
41
+ ## Methods
42
+
43
+ | Method | Needs | Returns |
44
+ | --- | --- | --- |
45
+ | `listTables()` | apiKey | table names |
46
+ | `from(table).select(cols?)` | apiKey | rows; chainable with `.where()`, `.order()`, `.limit()`, `.offset()` |
47
+ | `insert(table, data)` | apiKey | the inserted row(s) |
48
+ | `update(table, id, data)` | apiKey | the updated row; throws if nothing matched |
49
+ | `delete(table, id)` | apiKey | `{ message }` |
50
+ | `sql(projectId, query)` | token | one entry per statement, each with `rows` and `rowCount` |
51
+ | `createBucket(name)` | token | the new bucket |
52
+ | `listBuckets()` | token | your buckets |
53
+ | `uploadFile(bucketId, path, file)` | token | the stored object |
54
+ | `listFiles(bucketId, path?)` | token | folder entries |
55
+ | `deleteFile(bucketId, path)` | token | confirmation |
56
+
57
+ The query builder is awaitable directly — `await db.from("t").limit(1)` works without calling
58
+ `.run()`.
59
+
60
+ ## Errors
61
+
62
+ Every non-2xx response throws `KroomBaseError` with `status`, `body`, and `message`:
63
+
64
+ ```ts
65
+ import { KroomBaseError } from "@kroombase/client";
66
+
67
+ try {
68
+ await db.update("orders", 999, { status: "x" });
69
+ } catch (err) {
70
+ if (err instanceof KroomBaseError) console.error(err.status, err.message);
71
+ }
72
+ ```
73
+
74
+ ## Honest limits
75
+
76
+ These are properties of the REST API this client wraps, documented so you are not surprised:
77
+
78
+ - **`delete()` cannot confirm a deletion.** `DELETE /rest/:table/:id` answers
79
+ `{message:"row deleted"}` whether or not a row matched. Read first, or use `sql()` with
80
+ `DELETE ... RETURNING`, when that matters.
81
+ - **`update()` throws on a missing row.** The route itself answers `200 []`; this client turns
82
+ that into a 404 error, because a write that silently did nothing is worse than a failure.
83
+ - **Raw SQL needs a session token.** API keys are refused by `/query`; that is deliberate, so a
84
+ leaked key cannot drop tables.
85
+
86
+ ## Other languages
87
+
88
+ The SDK is a convenience, not a requirement. The REST API is one header and plain JSON:
89
+
90
+ ```bash
91
+ curl "https://kroombase.kroombox.com/rest/orders?limit=5" -H "apikey: kb_your_api_key"
92
+ ```
93
+
94
+ See the docs in the app for Python and raw `curl` equivalents.
package/dist/index.cjs ADDED
@@ -0,0 +1,248 @@
1
+ "use strict";
2
+ /**
3
+ * KroomBase client for JavaScript and TypeScript.
4
+ *
5
+ * A thin, typed wrapper over the REST API: one method per operation, no hidden state, no
6
+ * retries you did not ask for. The query builder only exposes what the server actually
7
+ * accepts — a single filter per request, row ids as integers — so a call that compiles is a
8
+ * call the API will answer.
9
+ *
10
+ * import { KroomBase } from "@kroombase/client";
11
+ * const db = new KroomBase({ apiKey: "kbase_..." });
12
+ * const users = await db.from("users").select().where("age", "gt", 21).limit(10);
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.QueryBuilder = exports.KroomBase = exports.KroomBaseError = void 0;
16
+ /** Thrown for any non-2xx answer, carrying the status and the server's own error body. */
17
+ class KroomBaseError extends Error {
18
+ status;
19
+ body;
20
+ constructor(status, body, message) {
21
+ super(message || `KroomBase request failed with status ${status}`);
22
+ this.name = "KroomBaseError";
23
+ this.status = status;
24
+ this.body = body;
25
+ }
26
+ }
27
+ exports.KroomBaseError = KroomBaseError;
28
+ function encodeQuery(params) {
29
+ const search = new URLSearchParams();
30
+ for (const [key, value] of Object.entries(params)) {
31
+ if (value === undefined || value === null || value === "")
32
+ continue;
33
+ search.set(key, String(value));
34
+ }
35
+ const s = search.toString();
36
+ return s ? `?${s}` : "";
37
+ }
38
+ class KroomBase {
39
+ baseUrl;
40
+ headers;
41
+ timeoutMs;
42
+ onRequest;
43
+ constructor(options) {
44
+ if (!options?.apiKey && !options?.token) {
45
+ throw new Error("KroomBase: pass either apiKey (REST + MCP database tools) or token (SQL + storage).");
46
+ }
47
+ this.baseUrl = (options.url || "https://kroombase.kroombox.com").replace(/\/+$/, "");
48
+ this.headers = options.apiKey
49
+ ? { apikey: options.apiKey }
50
+ : { Authorization: `Bearer ${options.token}` };
51
+ this.timeoutMs = options.timeoutMs ?? 30000;
52
+ this.onRequest = options.onRequest;
53
+ }
54
+ async request(method, path, body) {
55
+ const url = `${this.baseUrl}${path}`;
56
+ this.onRequest?.({ method, url });
57
+ const controller = new AbortController();
58
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
59
+ try {
60
+ const res = await fetch(url, {
61
+ method,
62
+ headers: body === undefined
63
+ ? this.headers
64
+ : { ...this.headers, "Content-Type": "application/json" },
65
+ body: body === undefined ? undefined : JSON.stringify(body),
66
+ signal: controller.signal,
67
+ });
68
+ const text = await res.text();
69
+ const parsed = text ? safeJson(text) : null;
70
+ if (!res.ok) {
71
+ const detail = parsed && typeof parsed === "object" && "error" in parsed
72
+ ? String(parsed.error)
73
+ : undefined;
74
+ throw new KroomBaseError(res.status, parsed ?? text, detail);
75
+ }
76
+ return parsed;
77
+ }
78
+ finally {
79
+ clearTimeout(timer);
80
+ }
81
+ }
82
+ /** Start a query against one table. */
83
+ from(table) {
84
+ return new QueryBuilder(this, table);
85
+ }
86
+ /** List every table in the project's schema. */
87
+ listTables() {
88
+ return this.request("GET", "/rest").then((rows) => rows.map((r) => r.table_name));
89
+ }
90
+ /** Insert one row and get it back. */
91
+ insert(table, data) {
92
+ return this.request("POST", `/rest/${encodeURIComponent(table)}`, data);
93
+ }
94
+ /**
95
+ * Update one row by integer id and return it.
96
+ *
97
+ * Throws when nothing matched: the REST route answers `200 []` for an id that does not exist,
98
+ * and a write that silently did nothing is worse than an error.
99
+ */
100
+ async update(table, id, data) {
101
+ const rows = await this.request("PUT", `/rest/${encodeURIComponent(table)}/${id}`, data);
102
+ if (!Array.isArray(rows) || rows.length === 0) {
103
+ throw new KroomBaseError(404, null, `no row with id ${id} in ${table} — nothing was updated`);
104
+ }
105
+ return rows;
106
+ }
107
+ /**
108
+ * Delete one row by integer id.
109
+ *
110
+ * The REST route always answers `{message:"row deleted"}`, whether or not a row matched, so
111
+ * this cannot tell you if anything was removed. When that matters, read the row first or use
112
+ * `sql()` with `DELETE ... RETURNING`.
113
+ */
114
+ delete(table, id) {
115
+ return this.request("DELETE", `/rest/${encodeURIComponent(table)}/${id}`);
116
+ }
117
+ /**
118
+ * Run raw SQL. Requires a session token: the API key is refused by this endpoint, by design.
119
+ * `projectId` is the full 32-character project id.
120
+ */
121
+ sql(projectId, query) {
122
+ return this.request("POST", "/query", {
123
+ projectId,
124
+ query,
125
+ }).then((r) =>
126
+ // `/query` answers one entry per statement under `results`, each keyed by the statement
127
+ // text it ran — not by an index, so the shape is normalised here.
128
+ (r.results || []).map((s) => ({ statement: s.query, rows: s.rows, rowCount: s.rowCount })));
129
+ }
130
+ /** List the buckets you own. Requires a session token. */
131
+ listBuckets() {
132
+ return this.request("GET", "/storage/buckets").then((r) => r.buckets);
133
+ }
134
+ /** Create a bucket. Requires a session token. */
135
+ createBucket(name) {
136
+ return this.request("POST", "/storage/buckets", { name });
137
+ }
138
+ /** List files and folders in a bucket folder. Requires a session token. */
139
+ listObjects(bucketId, path = "") {
140
+ return this.request("GET", `/storage/buckets/${bucketId}/object${encodeQuery({ path })}`);
141
+ }
142
+ /** Upload a file. Requires a session token. */
143
+ async upload(bucketId, fileName, content, path) {
144
+ const form = new FormData();
145
+ const blob = content instanceof Blob ? content : new Blob([content]);
146
+ form.append("file", blob, fileName);
147
+ if (path)
148
+ form.append("path", path);
149
+ const res = await fetch(`${this.baseUrl}/storage/buckets/${bucketId}/object`, {
150
+ method: "POST",
151
+ headers: this.headers,
152
+ body: form,
153
+ });
154
+ const text = await res.text();
155
+ const parsed = text ? safeJson(text) : null;
156
+ if (!res.ok)
157
+ throw new KroomBaseError(res.status, parsed ?? text);
158
+ return parsed;
159
+ }
160
+ /** Delete an object or folder. Requires a session token. */
161
+ removeObject(bucketId, path) {
162
+ return this.request("DELETE", `/storage/buckets/${bucketId}/object${encodeQuery({ path })}`);
163
+ }
164
+ /**
165
+ * Fetch a file's bytes. Storage has no public URLs on purpose, so this goes through the
166
+ * authenticated route and hands back the bytes.
167
+ */
168
+ async download(bucketId, path) {
169
+ const url = `${this.baseUrl}/storage/buckets/${bucketId}/download/${path
170
+ .split("/")
171
+ .map(encodeURIComponent)
172
+ .join("/")}`;
173
+ this.onRequest?.({ method: "GET", url });
174
+ const res = await fetch(url, { headers: this.headers });
175
+ if (!res.ok)
176
+ throw new KroomBaseError(res.status, await res.text().catch(() => ""));
177
+ return res.blob();
178
+ }
179
+ }
180
+ exports.KroomBase = KroomBase;
181
+ /**
182
+ * A read query. The server accepts exactly one filter per request, so chaining `where` twice
183
+ * keeps the last one rather than silently building something the API would reject.
184
+ */
185
+ class QueryBuilder {
186
+ client;
187
+ table;
188
+ _columns = "*";
189
+ _column;
190
+ _op = "=";
191
+ _value;
192
+ _order;
193
+ _limit = 50;
194
+ _offset = 0;
195
+ constructor(client, table) {
196
+ this.client = client;
197
+ this.table = table;
198
+ }
199
+ select(columns = "*") {
200
+ this._columns = Array.isArray(columns) ? columns.join(",") : columns;
201
+ return this;
202
+ }
203
+ where(column, op, value) {
204
+ this._column = column;
205
+ this._op = op;
206
+ this._value = typeof value === "string" ? value.replace(/\0/g, "") : value;
207
+ return this;
208
+ }
209
+ order(column, direction = "asc") {
210
+ this._order = `${column}.${direction}`;
211
+ return this;
212
+ }
213
+ limit(n) {
214
+ this._limit = n;
215
+ return this;
216
+ }
217
+ offset(n) {
218
+ this._offset = n;
219
+ return this;
220
+ }
221
+ /** Run the query. */
222
+ async run() {
223
+ const path = `/rest/${encodeURIComponent(this.table)}${encodeQuery({
224
+ select: this._columns === "*" ? undefined : this._columns,
225
+ column: this._column,
226
+ op: this._column ? this._op : undefined,
227
+ value: this._value,
228
+ order: this._order,
229
+ limit: this._limit,
230
+ offset: this._offset,
231
+ })}`;
232
+ return this.client.request("GET", path);
233
+ }
234
+ /** So `await db.from("users").select()` works without calling `.run()`. */
235
+ then(onfulfilled, onrejected) {
236
+ return this.run().then(onfulfilled, onrejected);
237
+ }
238
+ }
239
+ exports.QueryBuilder = QueryBuilder;
240
+ function safeJson(text) {
241
+ try {
242
+ return JSON.parse(text);
243
+ }
244
+ catch {
245
+ return text;
246
+ }
247
+ }
248
+ exports.default = KroomBase;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * KroomBase client for JavaScript and TypeScript.
3
+ *
4
+ * A thin, typed wrapper over the REST API: one method per operation, no hidden state, no
5
+ * retries you did not ask for. The query builder only exposes what the server actually
6
+ * accepts — a single filter per request, row ids as integers — so a call that compiles is a
7
+ * call the API will answer.
8
+ *
9
+ * import { KroomBase } from "@kroombase/client";
10
+ * const db = new KroomBase({ apiKey: "kbase_..." });
11
+ * const users = await db.from("users").select().where("age", "gt", 21).limit(10);
12
+ */
13
+ export interface KroomBaseOptions {
14
+ /** Project API key. Takes precedence over `token` when both are set. */
15
+ apiKey?: string;
16
+ /** Session token (JWT) for the SQL and storage surfaces. */
17
+ token?: string;
18
+ /** Defaults to https://kroombase.kroombox.com */
19
+ url?: string;
20
+ /** Per-request timeout in milliseconds. Defaults to 30000. */
21
+ timeoutMs?: number;
22
+ /** Receives every request and response — wire up your logger here. */
23
+ onRequest?: (info: {
24
+ method: string;
25
+ url: string;
26
+ }) => void;
27
+ }
28
+ export type FilterOp = "=" | "gt" | "lt" | "gte" | "lte" | "like";
29
+ export type Row = Record<string, unknown>;
30
+ /** Thrown for any non-2xx answer, carrying the status and the server's own error body. */
31
+ export declare class KroomBaseError extends Error {
32
+ readonly status: number;
33
+ readonly body: unknown;
34
+ constructor(status: number, body: unknown, message?: string);
35
+ }
36
+ export declare class KroomBase {
37
+ private readonly baseUrl;
38
+ private readonly headers;
39
+ private readonly timeoutMs;
40
+ private readonly onRequest?;
41
+ constructor(options: KroomBaseOptions);
42
+ private request;
43
+ /** Start a query against one table. */
44
+ from(table: string): QueryBuilder;
45
+ /** List every table in the project's schema. */
46
+ listTables(): Promise<string[]>;
47
+ /** Insert one row and get it back. */
48
+ insert(table: string, data: Row): Promise<Row[]>;
49
+ /**
50
+ * Update one row by integer id and return it.
51
+ *
52
+ * Throws when nothing matched: the REST route answers `200 []` for an id that does not exist,
53
+ * and a write that silently did nothing is worse than an error.
54
+ */
55
+ update(table: string, id: number, data: Row): Promise<Row[]>;
56
+ /**
57
+ * Delete one row by integer id.
58
+ *
59
+ * The REST route always answers `{message:"row deleted"}`, whether or not a row matched, so
60
+ * this cannot tell you if anything was removed. When that matters, read the row first or use
61
+ * `sql()` with `DELETE ... RETURNING`.
62
+ */
63
+ delete(table: string, id: number): Promise<{
64
+ message: string;
65
+ }>;
66
+ /**
67
+ * Run raw SQL. Requires a session token: the API key is refused by this endpoint, by design.
68
+ * `projectId` is the full 32-character project id.
69
+ */
70
+ sql<T = Row>(projectId: string, query: string): Promise<{
71
+ statement: string;
72
+ rows?: T[];
73
+ rowCount: number;
74
+ }[]>;
75
+ /** List the buckets you own. Requires a session token. */
76
+ listBuckets(): Promise<Bucket[]>;
77
+ /** Create a bucket. Requires a session token. */
78
+ createBucket(name: string): Promise<Bucket>;
79
+ /** List files and folders in a bucket folder. Requires a session token. */
80
+ listObjects(bucketId: string, path?: string): Promise<unknown>;
81
+ /** Upload a file. Requires a session token. */
82
+ upload(bucketId: string, fileName: string, content: Blob | Uint8Array, path?: string): Promise<unknown>;
83
+ /** Delete an object or folder. Requires a session token. */
84
+ removeObject(bucketId: string, path: string): Promise<unknown>;
85
+ /**
86
+ * Fetch a file's bytes. Storage has no public URLs on purpose, so this goes through the
87
+ * authenticated route and hands back the bytes.
88
+ */
89
+ download(bucketId: string, path: string): Promise<Blob>;
90
+ }
91
+ export interface Bucket {
92
+ _id: string;
93
+ name: string;
94
+ files?: number;
95
+ folders?: number;
96
+ bytes?: number;
97
+ }
98
+ /**
99
+ * A read query. The server accepts exactly one filter per request, so chaining `where` twice
100
+ * keeps the last one rather than silently building something the API would reject.
101
+ */
102
+ export declare class QueryBuilder implements PromiseLike<Row[]> {
103
+ private readonly client;
104
+ private readonly table;
105
+ private _columns;
106
+ private _column?;
107
+ private _op;
108
+ private _value?;
109
+ private _order?;
110
+ private _limit;
111
+ private _offset;
112
+ constructor(client: KroomBase, table: string);
113
+ select(columns?: string[] | string): this;
114
+ where(column: string, op: FilterOp, value: string | number): this;
115
+ order(column: string, direction?: "asc" | "desc"): this;
116
+ limit(n: number): this;
117
+ offset(n: number): this;
118
+ /** Run the query. */
119
+ run(): Promise<Row[]>;
120
+ /** So `await db.from("users").select()` works without calling `.run()`. */
121
+ then<TResult1 = Row[], TResult2 = never>(onfulfilled?: ((value: Row[]) => TResult1 | PromiseLike<TResult1>) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null): PromiseLike<TResult1 | TResult2>;
122
+ }
123
+ export default KroomBase;
package/dist/index.js ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * KroomBase client for JavaScript and TypeScript.
3
+ *
4
+ * A thin, typed wrapper over the REST API: one method per operation, no hidden state, no
5
+ * retries you did not ask for. The query builder only exposes what the server actually
6
+ * accepts — a single filter per request, row ids as integers — so a call that compiles is a
7
+ * call the API will answer.
8
+ *
9
+ * import { KroomBase } from "@kroombase/client";
10
+ * const db = new KroomBase({ apiKey: "kbase_..." });
11
+ * const users = await db.from("users").select().where("age", "gt", 21).limit(10);
12
+ */
13
+ /** Thrown for any non-2xx answer, carrying the status and the server's own error body. */
14
+ export class KroomBaseError extends Error {
15
+ status;
16
+ body;
17
+ constructor(status, body, message) {
18
+ super(message || `KroomBase request failed with status ${status}`);
19
+ this.name = "KroomBaseError";
20
+ this.status = status;
21
+ this.body = body;
22
+ }
23
+ }
24
+ function encodeQuery(params) {
25
+ const search = new URLSearchParams();
26
+ for (const [key, value] of Object.entries(params)) {
27
+ if (value === undefined || value === null || value === "")
28
+ continue;
29
+ search.set(key, String(value));
30
+ }
31
+ const s = search.toString();
32
+ return s ? `?${s}` : "";
33
+ }
34
+ export class KroomBase {
35
+ baseUrl;
36
+ headers;
37
+ timeoutMs;
38
+ onRequest;
39
+ constructor(options) {
40
+ if (!options?.apiKey && !options?.token) {
41
+ throw new Error("KroomBase: pass either apiKey (REST + MCP database tools) or token (SQL + storage).");
42
+ }
43
+ this.baseUrl = (options.url || "https://kroombase.kroombox.com").replace(/\/+$/, "");
44
+ this.headers = options.apiKey
45
+ ? { apikey: options.apiKey }
46
+ : { Authorization: `Bearer ${options.token}` };
47
+ this.timeoutMs = options.timeoutMs ?? 30000;
48
+ this.onRequest = options.onRequest;
49
+ }
50
+ async request(method, path, body) {
51
+ const url = `${this.baseUrl}${path}`;
52
+ this.onRequest?.({ method, url });
53
+ const controller = new AbortController();
54
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
55
+ try {
56
+ const res = await fetch(url, {
57
+ method,
58
+ headers: body === undefined
59
+ ? this.headers
60
+ : { ...this.headers, "Content-Type": "application/json" },
61
+ body: body === undefined ? undefined : JSON.stringify(body),
62
+ signal: controller.signal,
63
+ });
64
+ const text = await res.text();
65
+ const parsed = text ? safeJson(text) : null;
66
+ if (!res.ok) {
67
+ const detail = parsed && typeof parsed === "object" && "error" in parsed
68
+ ? String(parsed.error)
69
+ : undefined;
70
+ throw new KroomBaseError(res.status, parsed ?? text, detail);
71
+ }
72
+ return parsed;
73
+ }
74
+ finally {
75
+ clearTimeout(timer);
76
+ }
77
+ }
78
+ /** Start a query against one table. */
79
+ from(table) {
80
+ return new QueryBuilder(this, table);
81
+ }
82
+ /** List every table in the project's schema. */
83
+ listTables() {
84
+ return this.request("GET", "/rest").then((rows) => rows.map((r) => r.table_name));
85
+ }
86
+ /** Insert one row and get it back. */
87
+ insert(table, data) {
88
+ return this.request("POST", `/rest/${encodeURIComponent(table)}`, data);
89
+ }
90
+ /**
91
+ * Update one row by integer id and return it.
92
+ *
93
+ * Throws when nothing matched: the REST route answers `200 []` for an id that does not exist,
94
+ * and a write that silently did nothing is worse than an error.
95
+ */
96
+ async update(table, id, data) {
97
+ const rows = await this.request("PUT", `/rest/${encodeURIComponent(table)}/${id}`, data);
98
+ if (!Array.isArray(rows) || rows.length === 0) {
99
+ throw new KroomBaseError(404, null, `no row with id ${id} in ${table} — nothing was updated`);
100
+ }
101
+ return rows;
102
+ }
103
+ /**
104
+ * Delete one row by integer id.
105
+ *
106
+ * The REST route always answers `{message:"row deleted"}`, whether or not a row matched, so
107
+ * this cannot tell you if anything was removed. When that matters, read the row first or use
108
+ * `sql()` with `DELETE ... RETURNING`.
109
+ */
110
+ delete(table, id) {
111
+ return this.request("DELETE", `/rest/${encodeURIComponent(table)}/${id}`);
112
+ }
113
+ /**
114
+ * Run raw SQL. Requires a session token: the API key is refused by this endpoint, by design.
115
+ * `projectId` is the full 32-character project id.
116
+ */
117
+ sql(projectId, query) {
118
+ return this.request("POST", "/query", {
119
+ projectId,
120
+ query,
121
+ }).then((r) =>
122
+ // `/query` answers one entry per statement under `results`, each keyed by the statement
123
+ // text it ran — not by an index, so the shape is normalised here.
124
+ (r.results || []).map((s) => ({ statement: s.query, rows: s.rows, rowCount: s.rowCount })));
125
+ }
126
+ /** List the buckets you own. Requires a session token. */
127
+ listBuckets() {
128
+ return this.request("GET", "/storage/buckets").then((r) => r.buckets);
129
+ }
130
+ /** Create a bucket. Requires a session token. */
131
+ createBucket(name) {
132
+ return this.request("POST", "/storage/buckets", { name });
133
+ }
134
+ /** List files and folders in a bucket folder. Requires a session token. */
135
+ listObjects(bucketId, path = "") {
136
+ return this.request("GET", `/storage/buckets/${bucketId}/object${encodeQuery({ path })}`);
137
+ }
138
+ /** Upload a file. Requires a session token. */
139
+ async upload(bucketId, fileName, content, path) {
140
+ const form = new FormData();
141
+ const blob = content instanceof Blob ? content : new Blob([content]);
142
+ form.append("file", blob, fileName);
143
+ if (path)
144
+ form.append("path", path);
145
+ const res = await fetch(`${this.baseUrl}/storage/buckets/${bucketId}/object`, {
146
+ method: "POST",
147
+ headers: this.headers,
148
+ body: form,
149
+ });
150
+ const text = await res.text();
151
+ const parsed = text ? safeJson(text) : null;
152
+ if (!res.ok)
153
+ throw new KroomBaseError(res.status, parsed ?? text);
154
+ return parsed;
155
+ }
156
+ /** Delete an object or folder. Requires a session token. */
157
+ removeObject(bucketId, path) {
158
+ return this.request("DELETE", `/storage/buckets/${bucketId}/object${encodeQuery({ path })}`);
159
+ }
160
+ /**
161
+ * Fetch a file's bytes. Storage has no public URLs on purpose, so this goes through the
162
+ * authenticated route and hands back the bytes.
163
+ */
164
+ async download(bucketId, path) {
165
+ const url = `${this.baseUrl}/storage/buckets/${bucketId}/download/${path
166
+ .split("/")
167
+ .map(encodeURIComponent)
168
+ .join("/")}`;
169
+ this.onRequest?.({ method: "GET", url });
170
+ const res = await fetch(url, { headers: this.headers });
171
+ if (!res.ok)
172
+ throw new KroomBaseError(res.status, await res.text().catch(() => ""));
173
+ return res.blob();
174
+ }
175
+ }
176
+ /**
177
+ * A read query. The server accepts exactly one filter per request, so chaining `where` twice
178
+ * keeps the last one rather than silently building something the API would reject.
179
+ */
180
+ export class QueryBuilder {
181
+ client;
182
+ table;
183
+ _columns = "*";
184
+ _column;
185
+ _op = "=";
186
+ _value;
187
+ _order;
188
+ _limit = 50;
189
+ _offset = 0;
190
+ constructor(client, table) {
191
+ this.client = client;
192
+ this.table = table;
193
+ }
194
+ select(columns = "*") {
195
+ this._columns = Array.isArray(columns) ? columns.join(",") : columns;
196
+ return this;
197
+ }
198
+ where(column, op, value) {
199
+ this._column = column;
200
+ this._op = op;
201
+ this._value = typeof value === "string" ? value.replace(/\0/g, "") : value;
202
+ return this;
203
+ }
204
+ order(column, direction = "asc") {
205
+ this._order = `${column}.${direction}`;
206
+ return this;
207
+ }
208
+ limit(n) {
209
+ this._limit = n;
210
+ return this;
211
+ }
212
+ offset(n) {
213
+ this._offset = n;
214
+ return this;
215
+ }
216
+ /** Run the query. */
217
+ async run() {
218
+ const path = `/rest/${encodeURIComponent(this.table)}${encodeQuery({
219
+ select: this._columns === "*" ? undefined : this._columns,
220
+ column: this._column,
221
+ op: this._column ? this._op : undefined,
222
+ value: this._value,
223
+ order: this._order,
224
+ limit: this._limit,
225
+ offset: this._offset,
226
+ })}`;
227
+ return this.client.request("GET", path);
228
+ }
229
+ /** So `await db.from("users").select()` works without calling `.run()`. */
230
+ then(onfulfilled, onrejected) {
231
+ return this.run().then(onfulfilled, onrejected);
232
+ }
233
+ }
234
+ function safeJson(text) {
235
+ try {
236
+ return JSON.parse(text);
237
+ }
238
+ catch {
239
+ return text;
240
+ }
241
+ }
242
+ export default KroomBase;
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@kroombase/client",
3
+ "version": "1.0.0",
4
+ "description": "KroomBase client for JavaScript and TypeScript — typed access to your hosted PostgreSQL backend over REST.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.cjs"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md"
20
+ ],
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "sideEffects": false,
25
+ "keywords": [
26
+ "kroombase",
27
+ "postgres",
28
+ "backend",
29
+ "rest",
30
+ "database",
31
+ "sdk"
32
+ ],
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.json && node build-cjs.mjs",
35
+ "prepublishOnly": "npm run build"
36
+ },
37
+ "devDependencies": {
38
+ "typescript": "^5.7.0"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/kroombase/kroombase-sdk.git",
43
+ "directory": "packages/client"
44
+ },
45
+ "homepage": "https://kroombase.kroombox.com/docs",
46
+ "publishConfig": {
47
+ "access": "public"
48
+ }
49
+ }