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/.changeset/config.json +11 -0
- package/.github/workflows/release.yml +42 -0
- package/CHANGELOG.md +9 -0
- package/CLAUDE.md +203 -0
- package/README.md +219 -0
- package/dist/index.d.ts +207 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +366 -0
- package/dist/index.js.map +1 -0
- package/examples/filters.mjs +131 -0
- package/package.json +20 -0
- package/src/index.ts +543 -0
- package/tsconfig.json +18 -0
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
|
+
}
|