@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 +94 -0
- package/dist/index.cjs +248 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.js +242 -0
- package/package.json +49 -0
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;
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|