prest-js-sdk 0.2.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/src/index.ts ADDED
@@ -0,0 +1,543 @@
1
+ /**
2
+ * prest-js-sdk
3
+ *
4
+ * TypeScript SDK for pREST (PostgreSQL REST API). Provides route-shaped methods
5
+ * for catalog/CRUD/stored-queries plus a typed filter DSL that serializes to
6
+ * pREST's `?field=op.value` URL syntax.
7
+ *
8
+ * Usage:
9
+ * import { PrestClient } from "prest-js-sdk";
10
+ *
11
+ * const client = new PrestClient("http://localhost:3000");
12
+ *
13
+ * // Catalog
14
+ * const dbs = await client.databases();
15
+ *
16
+ * // Query with typed filter
17
+ * const rows = await client.select<Balance>("yarsew", "public", "billing_balances", {
18
+ * where: { actor_id: { eq: 42 }, status: "active" },
19
+ * select: ["id", "balance"],
20
+ * order: ["balance:desc"],
21
+ * limit: 10,
22
+ * });
23
+ *
24
+ * // Insert
25
+ * const [row] = await client.insert<Balance>("yarsew", "public", "billing_balances", {
26
+ * actor_id: 42, balance: 100.50, status: "active",
27
+ * });
28
+ *
29
+ * // Update (filter + new values)
30
+ * const updated = await client.update<Balance>(
31
+ * "yarsew", "public", "billing_balances",
32
+ * { actor_id: 42 },
33
+ * { status: "frozen" },
34
+ * );
35
+ *
36
+ * // Delete
37
+ * await client.delete("yarsew", "public", "billing_balances", { actor_id: 42 });
38
+ *
39
+ * // Stored SQL (prest/etc/queries/reports/top_balances.sql)
40
+ * const top = await client.query<Balance>("reports", "top_balances", { min: 1000 });
41
+ *
42
+ * // Forward-compat Kratos auth (works once [auth.kratos] is wired into prest)
43
+ * const authed = await PrestClient.fromKratosSession(
44
+ * "http://localhost:4433",
45
+ * "http://localhost:3000",
46
+ * );
47
+ */
48
+
49
+ // ============================================================
50
+ // Types
51
+ // ============================================================
52
+
53
+ export interface PrestConfig {
54
+ /** pREST base URL, e.g. http://localhost:3000 */
55
+ prestUrl: string;
56
+ /** Optional bearer token (JWT from POST /auth, or Kratos session for forward-compat) */
57
+ authToken?: string;
58
+ /** Optional: pre-built Authorization header value (skips "Bearer " prefix) */
59
+ rawAuthHeader?: string;
60
+ /** Optional: extra headers merged into every request */
61
+ headers?: Record<string, string>;
62
+ }
63
+
64
+ /**
65
+ * Filter operators supported by pREST's `?field=op.value` syntax.
66
+ * Mirrors `prest/adapters/postgres/postgres.go` GetQueryOperator (line ~1474).
67
+ */
68
+ export type Operator =
69
+ | "eq" | "ne" | "gt" | "gte" | "lt" | "lte"
70
+ | "in" | "nin" | "any" | "some" | "all"
71
+ | "null" | "notnull" | "true" | "nottrue" | "false" | "notfalse"
72
+ | "like" | "ilike" | "nlike" | "nilike";
73
+
74
+ export type OpValue = string | number | (string | number)[];
75
+
76
+ /**
77
+ * Filter on table columns.
78
+ *
79
+ * - `{ field: value }` — shorthand for `{ field: { eq: value } }`
80
+ * - `{ field: null }` — IS NULL
81
+ * - `{ field: { op: value } }` — operator form, e.g. `{ age: { gt: 30 } }`
82
+ *
83
+ * Multiple fields are AND-ed. For OR across fields, use `SelectOpts.or`.
84
+ */
85
+ export type Filter = {
86
+ [field: string]:
87
+ | string
88
+ | number
89
+ | boolean
90
+ | null
91
+ | { [op in Operator]?: OpValue };
92
+ };
93
+
94
+ export interface SelectOpts {
95
+ /** WHERE clause — AND-ed across fields */
96
+ where?: Filter;
97
+ /** Column selection — `_select=col1,col2` */
98
+ select?: string[];
99
+ /** Order — `_order=col1,col2:desc` */
100
+ order?: string[];
101
+ /** Page-based pagination (1-indexed) — `_page=N&_size=M` */
102
+ page?: number;
103
+ size?: number;
104
+ /** Offset-based pagination — `_limit=N&_offset=M` */
105
+ limit?: number;
106
+ offset?: number;
107
+ /** DISTINCT — `_distinct=true` */
108
+ distinct?: boolean;
109
+ /** COUNT rows instead of returning them — `_count=true` */
110
+ count?: boolean;
111
+ /** Return just the count as a single object — `_count_first=true` */
112
+ countFirst?: boolean;
113
+ /** GROUP BY columns — `_groupby=col1,col2` */
114
+ groupBy?: string[];
115
+ /** OR-clauses for the WHERE — joined with OR inside parens */
116
+ or?: Filter[];
117
+ /** Columns to return from update/delete — `_returning=col1,col2` */
118
+ returning?: string[];
119
+ }
120
+
121
+ export class PrestApiError extends Error {
122
+ constructor(
123
+ public readonly status: number,
124
+ message: string,
125
+ public readonly body?: unknown,
126
+ ) {
127
+ super(`[${status}] ${message}`);
128
+ this.name = "PrestApiError";
129
+ }
130
+ }
131
+
132
+ // ============================================================
133
+ // Filter serializer (the heart of the DSL)
134
+ // ============================================================
135
+
136
+ const VALUELESS_OPS = new Set<Operator>([
137
+ "null", "notnull", "true", "nottrue", "false", "notfalse",
138
+ ]);
139
+
140
+ const ARRAY_OPS = new Set<Operator>(["in", "nin", "any", "some", "all"]);
141
+
142
+ function serializeOpValue(op: Operator, val: OpValue | undefined): string {
143
+ if (VALUELESS_OPS.has(op)) {
144
+ return op; // ?field=null
145
+ }
146
+ if (ARRAY_OPS.has(op)) {
147
+ const arr = Array.isArray(val) ? val : val === undefined ? [] : [val];
148
+ return `${op}.(${arr.join(",")})`;
149
+ }
150
+ const v = val === undefined ? "" : typeof val === "string" ? val : String(val);
151
+ return `${op}.${v}`;
152
+ }
153
+
154
+ /**
155
+ * Serialize a Filter into pREST URL query params.
156
+ *
157
+ * Each field becomes one or more entries: `field=op.value`. Multiple ops on the
158
+ * same field are emitted as repeated query params (pREST AND-s them).
159
+ *
160
+ * @example
161
+ * serializeFilter({ actor_id: 42 })
162
+ * // → "actor_id=eq.42"
163
+ * serializeFilter({ age: { gt: 30 }, name: { like: "foo%" } })
164
+ * // → "age=gt.30&name=like.foo%25"
165
+ * serializeFilter({ id: { in: [1, 2, 3] }, status: null })
166
+ * // → "id=in.(1,2,3)&status=null"
167
+ */
168
+ export function serializeFilter(filter: Filter): URLSearchParams {
169
+ const params = new URLSearchParams();
170
+ for (const [field, condition] of Object.entries(filter)) {
171
+ if (condition === null || condition === undefined) {
172
+ params.append(field, "null");
173
+ continue;
174
+ }
175
+ if (typeof condition === "object" && !Array.isArray(condition)) {
176
+ for (const [op, val] of Object.entries(condition)) {
177
+ params.append(field, serializeOpValue(op as Operator, val as OpValue));
178
+ }
179
+ continue;
180
+ }
181
+ // Primitive shorthand
182
+ if (typeof condition === "boolean") {
183
+ params.append(field, condition ? "true" : "false");
184
+ } else {
185
+ params.append(field, `eq.${condition}`);
186
+ }
187
+ }
188
+ return params;
189
+ }
190
+
191
+ /**
192
+ * Serialize full SelectOpts into URLSearchParams (filter + modifiers).
193
+ */
194
+ export function serializeSelectOpts(opts: SelectOpts): URLSearchParams {
195
+ const params = opts.where ? serializeFilter(opts.where) : new URLSearchParams();
196
+
197
+ if (opts.select?.length) params.set("_select", opts.select.join(","));
198
+ if (opts.order?.length) params.set("_order", opts.order.join(","));
199
+ if (opts.distinct) params.set("_distinct", "true");
200
+ if (opts.count) params.set("_count", "true");
201
+ if (opts.countFirst) params.set("_count_first", "true");
202
+ if (opts.groupBy?.length) params.set("_groupby", opts.groupBy.join(","));
203
+ if (opts.returning?.length) params.set("_returning", opts.returning.join(","));
204
+
205
+ if (opts.or?.length) {
206
+ // pREST format: _or=field=op.value,field=op.value
207
+ const parts: string[] = [];
208
+ for (const f of opts.or) {
209
+ const fp = serializeFilter(f);
210
+ fp.forEach((v, k) => parts.push(`${k}=${v}`));
211
+ }
212
+ params.set("_or", parts.join(","));
213
+ }
214
+
215
+ if (opts.page !== undefined) params.set("_page", String(opts.page));
216
+ if (opts.size !== undefined) params.set("_size", String(opts.size));
217
+ if (opts.limit !== undefined) params.set("_limit", String(opts.limit));
218
+ if (opts.offset !== undefined) params.set("_offset", String(opts.offset));
219
+
220
+ return params;
221
+ }
222
+
223
+ // ============================================================
224
+ // PrestClient
225
+ // ============================================================
226
+
227
+ export class PrestClient {
228
+ private readonly prestUrl: string;
229
+ private authHeader: string | undefined;
230
+ private readonly extraHeaders: Record<string, string>;
231
+
232
+ constructor(config: PrestConfig | string) {
233
+ if (typeof config === "string") {
234
+ // Shortcut: new PrestClient(url, authToken?)
235
+ this.prestUrl = config.replace(/\/+$/, "");
236
+ const token = arguments[1] as string | undefined;
237
+ this.authHeader = token ? `Bearer ${token}` : undefined;
238
+ this.extraHeaders = {};
239
+ } else {
240
+ this.prestUrl = config.prestUrl.replace(/\/+$/, "");
241
+ this.authHeader =
242
+ config.rawAuthHeader ??
243
+ (config.authToken ? `Bearer ${config.authToken}` : undefined);
244
+ this.extraHeaders = { ...(config.headers ?? {}) };
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Forward-compat factory: validate a Kratos session and build a PrestClient.
250
+ *
251
+ * The vanilla pREST submodule does not yet wire Kratos (the planned
252
+ * `[auth.kratos]` middleware is documented in the parent project's CLAUDE.md
253
+ * but not merged into the submodule). This helper validates the session
254
+ * against Kratos and stores it as a Bearer header, so it will Just Work
255
+ * once the integration lands. Today it's useful if you're running a patched
256
+ * prest build with Kratos auth.
257
+ *
258
+ * @param kratosUrl - Kratos public URL, e.g. http://localhost:4433
259
+ * @param prestUrl - pREST base URL, defaults to http://localhost:3000
260
+ * @param sessionToken - Optional token. Reads `ory_kratos_session` cookie if omitted (browser).
261
+ * @returns PrestClient, or null if no valid session was found.
262
+ */
263
+ static async fromKratosSession(
264
+ kratosUrl: string,
265
+ prestUrl = "http://localhost:3000",
266
+ sessionToken?: string,
267
+ ): Promise<PrestClient | null> {
268
+ const token =
269
+ sessionToken ??
270
+ (typeof document !== "undefined" ? readCookie("ory_kratos_session") : undefined);
271
+ if (!token) return null;
272
+
273
+ const valid = await validateKratosSession(kratosUrl, token);
274
+ if (!valid) return null;
275
+
276
+ return new PrestClient({ prestUrl, authToken: token });
277
+ }
278
+
279
+ /**
280
+ * Set or replace the auth token after construction.
281
+ * Useful after calling `login()`. Returns `this` for chaining.
282
+ */
283
+ setAuthToken(token: string): this {
284
+ this.authHeader = `Bearer ${token}`;
285
+ return this;
286
+ }
287
+
288
+ // ─── Low-level request helper ────────────────────────────────────────────
289
+
290
+ /**
291
+ * Escape hatch for endpoints not covered by the typed methods.
292
+ * Throws PrestApiError on non-2xx.
293
+ */
294
+ async request<T = unknown>(
295
+ method: string,
296
+ path: string,
297
+ init?: {
298
+ body?: BodyInit | null;
299
+ headers?: Record<string, string>;
300
+ params?: URLSearchParams;
301
+ },
302
+ ): Promise<T> {
303
+ const url = new URL(
304
+ `${path.startsWith("/") ? "" : "/"}${path}`,
305
+ this.prestUrl,
306
+ );
307
+ if (init?.params) {
308
+ init.params.forEach((v, k) => url.searchParams.append(k, v));
309
+ }
310
+
311
+ const res = await fetch(url.toString(), {
312
+ method,
313
+ headers: {
314
+ Accept: "application/json",
315
+ ...(this.authHeader ? { Authorization: this.authHeader } : {}),
316
+ ...this.extraHeaders,
317
+ ...(init?.body && !(init.body instanceof FormData)
318
+ ? { "Content-Type": "application/json" }
319
+ : {}),
320
+ ...init?.headers,
321
+ },
322
+ body: init?.body ?? null,
323
+ redirect: "manual",
324
+ });
325
+
326
+ const text = await res.text();
327
+ let body: unknown = null;
328
+ if (text) {
329
+ try {
330
+ body = JSON.parse(text);
331
+ } catch {
332
+ body = text;
333
+ }
334
+ }
335
+
336
+ if (!res.ok) {
337
+ const msg =
338
+ (body && typeof body === "object" && "error" in body
339
+ ? String((body as Record<string, unknown>).error)
340
+ : res.statusText) || `HTTP ${res.status}`;
341
+ throw new PrestApiError(res.status, msg, body);
342
+ }
343
+ return body as T;
344
+ }
345
+
346
+ // ─── Catalog ─────────────────────────────────────────────────────────────
347
+
348
+ /** `GET /databases` — list database names. */
349
+ databases(): Promise<unknown> {
350
+ return this.request("GET", "/databases");
351
+ }
352
+
353
+ /** `GET /schemas` — list schema names. */
354
+ schemas(): Promise<unknown> {
355
+ return this.request("GET", "/schemas");
356
+ }
357
+
358
+ /** `GET /tables` — list table names. */
359
+ tables(): Promise<unknown> {
360
+ return this.request("GET", "/tables");
361
+ }
362
+
363
+ /** `GET /{database}/{schema}` — list tables in a specific database/schema. */
364
+ tablesIn(database: string, schema: string): Promise<unknown> {
365
+ return this.request(
366
+ "GET",
367
+ `/${encodeURIComponent(database)}/${encodeURIComponent(schema)}`,
368
+ );
369
+ }
370
+
371
+ /** `GET /show/{database}/{schema}/{table}` — table description / columns. */
372
+ showTable(
373
+ database: string,
374
+ schema: string,
375
+ table: string,
376
+ ): Promise<Record<string, unknown>> {
377
+ return this.request(
378
+ "GET",
379
+ `/show/${encodeURIComponent(database)}/${encodeURIComponent(schema)}/${encodeURIComponent(table)}`,
380
+ );
381
+ }
382
+
383
+ // ─── CRUD ────────────────────────────────────────────────────────────────
384
+
385
+ /** `GET /{db}/{schema}/{table}` — SELECT with typed filter DSL. */
386
+ select<T = unknown>(
387
+ database: string,
388
+ schema: string,
389
+ table: string,
390
+ opts: SelectOpts = {},
391
+ ): Promise<T[]> {
392
+ const params = serializeSelectOpts(opts);
393
+ return this.request(
394
+ "GET",
395
+ `/${encodeURIComponent(database)}/${encodeURIComponent(schema)}/${encodeURIComponent(table)}`,
396
+ { params },
397
+ );
398
+ }
399
+
400
+ /** `POST /{db}/{schema}/{table}` — insert a single row. */
401
+ insert<T = unknown>(
402
+ database: string,
403
+ schema: string,
404
+ table: string,
405
+ data: Record<string, unknown>,
406
+ ): Promise<T[]> {
407
+ return this.request(
408
+ "POST",
409
+ `/${encodeURIComponent(database)}/${encodeURIComponent(schema)}/${encodeURIComponent(table)}`,
410
+ { body: JSON.stringify(data) },
411
+ );
412
+ }
413
+
414
+ /** `POST /batch/{db}/{schema}/{table}` — insert multiple rows in one call. */
415
+ insertBatch<T = unknown>(
416
+ database: string,
417
+ schema: string,
418
+ table: string,
419
+ rows: Record<string, unknown>[],
420
+ ): Promise<T[]> {
421
+ return this.request(
422
+ "POST",
423
+ `/batch/${encodeURIComponent(database)}/${encodeURIComponent(schema)}/${encodeURIComponent(table)}`,
424
+ { body: JSON.stringify(rows) },
425
+ );
426
+ }
427
+
428
+ /** `PUT /{db}/{schema}/{table}?filter` — update rows matching filter. */
429
+ update<T = unknown>(
430
+ database: string,
431
+ schema: string,
432
+ table: string,
433
+ where: Filter,
434
+ data: Record<string, unknown>,
435
+ ): Promise<T[]> {
436
+ const params = serializeFilter(where);
437
+ return this.request(
438
+ "PUT",
439
+ `/${encodeURIComponent(database)}/${encodeURIComponent(schema)}/${encodeURIComponent(table)}`,
440
+ { body: JSON.stringify(data), params },
441
+ );
442
+ }
443
+
444
+ /** `DELETE /{db}/{schema}/{table}?filter` — delete rows matching filter. */
445
+ delete<T = unknown>(
446
+ database: string,
447
+ schema: string,
448
+ table: string,
449
+ where: Filter,
450
+ ): Promise<T[]> {
451
+ const params = serializeFilter(where);
452
+ return this.request(
453
+ "DELETE",
454
+ `/${encodeURIComponent(database)}/${encodeURIComponent(schema)}/${encodeURIComponent(table)}`,
455
+ { params },
456
+ );
457
+ }
458
+
459
+ // ─── Stored queries ──────────────────────────────────────────────────────
460
+
461
+ /**
462
+ * Execute a stored SQL script: `GET /_QUERIES/{location}/{script}`.
463
+ *
464
+ * Scripts live in `<prest queries dir>/<location>/<script>.sql` and can
465
+ * reference params as `{{ sqlVal "key" }}` / `{{ sqlList "key" }}` in
466
+ * pREST's template syntax.
467
+ *
468
+ * @param location - Folder name under prest's `queries` directory.
469
+ * @param script - `.sql` filename without the extension.
470
+ * @param params - Rendered as URL query params; accessible in the SQL template.
471
+ */
472
+ query<T = unknown>(
473
+ location: string,
474
+ script: string,
475
+ params: Record<string, string | number | boolean> = {},
476
+ ): Promise<T[]> {
477
+ const qs = new URLSearchParams();
478
+ for (const [k, v] of Object.entries(params)) qs.set(k, String(v));
479
+ return this.request(
480
+ "GET",
481
+ `/_QUERIES/${encodeURIComponent(location)}/${encodeURIComponent(script)}`,
482
+ { params: qs },
483
+ );
484
+ }
485
+
486
+ // ─── Health & auth ───────────────────────────────────────────────────────
487
+
488
+ /** `GET /_health` — returns true on 2xx, false otherwise. */
489
+ async health(): Promise<boolean> {
490
+ try {
491
+ await this.request("GET", "/_health");
492
+ return true;
493
+ } catch {
494
+ return false;
495
+ }
496
+ }
497
+
498
+ /**
499
+ * `POST /auth` — exchange username/password for a JWT.
500
+ * Stores the returned token and attaches it as `Authorization: Bearer <token>`
501
+ * on subsequent requests.
502
+ *
503
+ * Only works when pREST's `[auth]` block is enabled in `prest.toml`.
504
+ * Returns the token string.
505
+ */
506
+ async login(username: string, password: string): Promise<string> {
507
+ const res = await this.request<{ token?: string }>("POST", "/auth", {
508
+ body: JSON.stringify({ username, password }),
509
+ });
510
+ const token = res.token;
511
+ if (!token) {
512
+ throw new PrestApiError(200, "auth response missing 'token' field", res);
513
+ }
514
+ this.setAuthToken(token);
515
+ return token;
516
+ }
517
+ }
518
+
519
+ // ============================================================
520
+ // Helpers (Kratos session validation — mirrors alist-kratos-sdk)
521
+ // ============================================================
522
+
523
+ function readCookie(name: string): string | undefined {
524
+ if (typeof document === "undefined") return undefined;
525
+ const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
526
+ return match ? decodeURIComponent(match[1]) : undefined;
527
+ }
528
+
529
+ async function validateKratosSession(
530
+ kratosUrl: string,
531
+ token: string,
532
+ ): Promise<boolean> {
533
+ try {
534
+ const res = await fetch(`${kratosUrl.replace(/\/+$/, "")}/sessions/whoami`, {
535
+ headers: { "X-Session-Token": token, Accept: "application/json" },
536
+ });
537
+ if (!res.ok) return false;
538
+ const session = (await res.json()) as { active?: boolean };
539
+ return session.active === true;
540
+ } catch {
541
+ return false;
542
+ }
543
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "outDir": "./dist",
8
+ "rootDir": "./src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "declaration": true,
12
+ "declarationMap": true,
13
+ "sourceMap": true,
14
+ "skipLibCheck": true
15
+ },
16
+ "include": ["src"],
17
+ "exclude": ["dist", "node_modules", "examples"]
18
+ }