@jxsuite/connector 0.6.2 → 0.6.3
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/package.json +3 -3
- package/src/columns.ts +28 -21
- package/src/connectors.ts +19 -8
- package/src/d1.ts +18 -14
- package/src/ddl.ts +8 -14
- package/src/dialects/bun-sqlite.ts +11 -0
- package/src/dialects/d1-http.ts +22 -6
- package/src/provider-utils.ts +28 -14
- package/src/query.ts +40 -23
- package/src/sqlite.ts +8 -6
- package/src/supabase.ts +10 -12
- package/src/table-actions.ts +34 -20
- package/src/table-delete.ts +2 -1
- package/src/table-insert.ts +2 -1
- package/src/table-node.ts +22 -21
- package/src/table-shared.ts +22 -18
- package/src/table-state.ts +33 -16
- package/src/table-update.ts +2 -1
- package/src/types.ts +30 -23
- package/src/validate.ts +25 -26
- package/src/worker.ts +128 -76
package/src/worker.ts
CHANGED
|
@@ -18,38 +18,42 @@
|
|
|
18
18
|
* Bun, and node: no framework, no node imports, no `new Function`.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { isJsonObject } from "@jxsuite/schema/guards";
|
|
21
22
|
import { Kysely } from "kysely";
|
|
22
23
|
import { fromStorage, newRowId, planTable, toStorage } from "./columns.ts";
|
|
23
24
|
import { syncTables } from "./ddl.ts";
|
|
24
25
|
import { applyFilter, applySort, columnForField, normalizeFilter, normalizeSort } from "./query.ts";
|
|
25
26
|
import { tablesForConnection } from "./provider-utils.ts";
|
|
26
27
|
import { validateRow } from "./validate.ts";
|
|
28
|
+
import type { JsonObject, JsonValue } from "@jxsuite/schema/types";
|
|
27
29
|
import type { Dialect } from "kysely";
|
|
28
30
|
import type { ColumnSpec, SqlDialectKind, TablePlan } from "./columns.ts";
|
|
29
|
-
import type {
|
|
31
|
+
import type { HostEnv } from "./provider-utils.ts";
|
|
32
|
+
import type { DynamicDatabase, DynamicRow, SqlValue } from "./query.ts";
|
|
30
33
|
import type {
|
|
31
34
|
AuthorizeDecision,
|
|
35
|
+
AuthorizeInput,
|
|
32
36
|
ConnectionDef,
|
|
33
37
|
JxServerContext,
|
|
34
38
|
PermissionAction,
|
|
35
39
|
TableDef,
|
|
36
40
|
} from "./types.ts";
|
|
41
|
+
import type { ValidationIssue } from "./validate.ts";
|
|
37
42
|
import { DEFAULT_PERMISSIONS } from "./types.ts";
|
|
38
43
|
|
|
39
44
|
/** The provider surface the data mount needs (a slice of ConnectorProvider). */
|
|
40
45
|
export interface DataMountProvider {
|
|
41
46
|
kind: SqlDialectKind;
|
|
42
|
-
dialect: (connection: ConnectionDef, env:
|
|
47
|
+
dialect: (connection: ConnectionDef, env: HostEnv) => Dialect | Promise<Dialect>;
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
export interface DataMountOptions {
|
|
46
51
|
/** Route subtree this mount owns. Defaults to "/_jx/data". */
|
|
47
52
|
basePath?: string;
|
|
48
|
-
/** Extension-contributed project sections (
|
|
53
|
+
/** Extension-contributed project sections (reads `data` and `connections`). */
|
|
49
54
|
sections: {
|
|
50
55
|
data?: Record<string, TableDef>;
|
|
51
56
|
connections?: Record<string, ConnectionDef>;
|
|
52
|
-
[key: string]: unknown;
|
|
53
57
|
};
|
|
54
58
|
/** Provider implementations keyed by `connector.provider` id. */
|
|
55
59
|
connectors: Record<string, DataMountProvider>;
|
|
@@ -67,18 +71,40 @@ export function createDataMountState(): DataMountState {
|
|
|
67
71
|
return { dbs: new Map() };
|
|
68
72
|
}
|
|
69
73
|
|
|
70
|
-
|
|
74
|
+
/** The JSON body of every error response; `issues` lists per-field validation failures. */
|
|
75
|
+
interface DataErrorBody {
|
|
76
|
+
error: string;
|
|
77
|
+
issues?: ValidationIssue[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A row as the read path returns it: the stored columns `fromStorage` decodes, plus one member per
|
|
82
|
+
* `?include=` field holding the referenced row (to-one) or rows (to-many).
|
|
83
|
+
*/
|
|
84
|
+
interface PayloadRow {
|
|
85
|
+
[field: string]: SqlValue | PayloadRow | PayloadRow[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A validated payload split into storage columns and to-many junction values. */
|
|
89
|
+
interface SplitRow {
|
|
90
|
+
row: JsonObject;
|
|
91
|
+
junctionValues: JsonObject;
|
|
92
|
+
}
|
|
71
93
|
|
|
72
94
|
/** JSON error response helper. */
|
|
73
|
-
function fail(status: number, error: string, issues?:
|
|
74
|
-
|
|
95
|
+
function fail(status: number, error: string, issues?: ValidationIssue[]): Response {
|
|
96
|
+
const body: DataErrorBody = { error };
|
|
97
|
+
if (issues !== undefined) {
|
|
98
|
+
body.issues = issues;
|
|
99
|
+
}
|
|
100
|
+
return Response.json(body, { status });
|
|
75
101
|
}
|
|
76
102
|
|
|
77
103
|
/**
|
|
78
104
|
* Handle one /_jx/data request.
|
|
79
105
|
*
|
|
80
106
|
* @param {Request} request
|
|
81
|
-
* @param {
|
|
107
|
+
* @param {HostEnv} env - Worker env (bindings + vars)
|
|
82
108
|
* @param {DataMountOptions} options
|
|
83
109
|
* @param {JxServerContext} ctx - Shared mount context (auth hooks live here)
|
|
84
110
|
* @param {DataMountState} [state] - Memoized connections; omit for a throwaway state
|
|
@@ -86,7 +112,7 @@ function fail(status: number, error: string, issues?: unknown): Response {
|
|
|
86
112
|
*/
|
|
87
113
|
export async function handleDataRequest(
|
|
88
114
|
request: Request,
|
|
89
|
-
env:
|
|
115
|
+
env: HostEnv,
|
|
90
116
|
options: DataMountOptions,
|
|
91
117
|
ctx: JxServerContext,
|
|
92
118
|
state: DataMountState = createDataMountState(),
|
|
@@ -96,11 +122,14 @@ export async function handleDataRequest(
|
|
|
96
122
|
if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
|
|
97
123
|
return fail(404, "Not found");
|
|
98
124
|
}
|
|
99
|
-
|
|
100
|
-
|
|
125
|
+
// At most two segments: the table name and an optional row id.
|
|
126
|
+
const [tableName, rawId, ...extra] = url.pathname
|
|
127
|
+
.slice(basePath.length)
|
|
128
|
+
.split("/")
|
|
129
|
+
.filter(Boolean);
|
|
130
|
+
if (tableName === undefined || extra.length > 0) {
|
|
101
131
|
return fail(404, "Not found");
|
|
102
132
|
}
|
|
103
|
-
const [tableName, rawId] = segments as [string, string?];
|
|
104
133
|
|
|
105
134
|
const tables = options.sections.data ?? {};
|
|
106
135
|
const tableDef = tables[tableName];
|
|
@@ -171,14 +200,13 @@ export const Data = {
|
|
|
171
200
|
/** Static `mount` capability (specs/extensions.md §11): one shared handler per isolate. */
|
|
172
201
|
mount(options: DataMountOptions, ctx: JxServerContext) {
|
|
173
202
|
const state = createDataMountState();
|
|
174
|
-
return (request: Request, env:
|
|
203
|
+
return (request: Request, env: HostEnv): Promise<Response> =>
|
|
175
204
|
handleDataRequest(request, env, options, ctx, state);
|
|
176
205
|
},
|
|
177
206
|
|
|
178
207
|
/** Static `projectData` capability: expose the table definitions as `_project.data`. */
|
|
179
|
-
projectData(sectionValue:
|
|
180
|
-
|
|
181
|
-
return { ...section };
|
|
208
|
+
projectData(sectionValue: Record<string, TableDef> | null | undefined) {
|
|
209
|
+
return { ...sectionValue };
|
|
182
210
|
},
|
|
183
211
|
};
|
|
184
212
|
|
|
@@ -191,7 +219,7 @@ export const Data = {
|
|
|
191
219
|
*/
|
|
192
220
|
async function authorize(
|
|
193
221
|
request: Request,
|
|
194
|
-
env:
|
|
222
|
+
env: HostEnv,
|
|
195
223
|
ctx: JxServerContext,
|
|
196
224
|
table: string,
|
|
197
225
|
tableDef: TableDef,
|
|
@@ -210,16 +238,11 @@ async function authorize(
|
|
|
210
238
|
return fail(401, `${action} on "${table}" requires authentication (no auth mount configured)`);
|
|
211
239
|
}
|
|
212
240
|
const session = await auth.getSession(request, env);
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
table,
|
|
219
|
-
...(tableDef.ownerField === undefined ? {} : { ownerField: tableDef.ownerField }),
|
|
220
|
-
},
|
|
221
|
-
env,
|
|
222
|
-
);
|
|
241
|
+
const input: AuthorizeInput = { action, rule, session, table };
|
|
242
|
+
if (tableDef.ownerField !== undefined) {
|
|
243
|
+
input.ownerField = tableDef.ownerField;
|
|
244
|
+
}
|
|
245
|
+
const decision = await auth.authorize(input, env);
|
|
223
246
|
if (!decision.allow) {
|
|
224
247
|
const status = decision.status ?? (session ? 403 : 401);
|
|
225
248
|
return fail(status, decision.error ?? "Forbidden");
|
|
@@ -233,7 +256,7 @@ async function authorize(
|
|
|
233
256
|
function getDb(
|
|
234
257
|
state: DataMountState,
|
|
235
258
|
options: DataMountOptions,
|
|
236
|
-
env:
|
|
259
|
+
env: HostEnv,
|
|
237
260
|
connectionName: string,
|
|
238
261
|
): Promise<{ db: Kysely<DynamicDatabase>; kind: SqlDialectKind }> {
|
|
239
262
|
const memo = state.dbs.get(connectionName);
|
|
@@ -274,8 +297,8 @@ function parseId(plan: TablePlan, rawId: string): string | number | undefined {
|
|
|
274
297
|
return rawId;
|
|
275
298
|
}
|
|
276
299
|
|
|
277
|
-
/** Apply whereOwner to any builder with a `where(
|
|
278
|
-
function applyOwner<T extends { where: (
|
|
300
|
+
/** Apply whereOwner to any builder with a `where(column, "=", value)` method. */
|
|
301
|
+
function applyOwner<T extends { where: (column: string, op: "=", value: JsonValue) => T }>(
|
|
279
302
|
qb: T,
|
|
280
303
|
decision: AuthorizeDecision,
|
|
281
304
|
specs: ColumnSpec[],
|
|
@@ -284,8 +307,7 @@ function applyOwner<T extends { where: (col: never, op: never, value: never) =>
|
|
|
284
307
|
if (!owner) {
|
|
285
308
|
return qb;
|
|
286
309
|
}
|
|
287
|
-
|
|
288
|
-
return qb.where(column as never, "=" as never, owner.value as never);
|
|
310
|
+
return qb.where(columnForField(owner.field, specs), "=", owner.value);
|
|
289
311
|
}
|
|
290
312
|
|
|
291
313
|
async function listRows(
|
|
@@ -297,8 +319,8 @@ async function listRows(
|
|
|
297
319
|
decision: AuthorizeDecision,
|
|
298
320
|
): Promise<Response> {
|
|
299
321
|
const params = url.searchParams;
|
|
300
|
-
let filter:
|
|
301
|
-
let sort:
|
|
322
|
+
let filter: JsonValue;
|
|
323
|
+
let sort: JsonValue;
|
|
302
324
|
try {
|
|
303
325
|
filter = parseJsonParam(params.get("filter"));
|
|
304
326
|
sort = parseJsonParam(params.get("sort"));
|
|
@@ -324,8 +346,14 @@ async function listRows(
|
|
|
324
346
|
}
|
|
325
347
|
|
|
326
348
|
const raw = await qb.execute();
|
|
327
|
-
|
|
328
|
-
|
|
349
|
+
const rows = await expandIncludes(
|
|
350
|
+
db,
|
|
351
|
+
plan,
|
|
352
|
+
tables,
|
|
353
|
+
kind,
|
|
354
|
+
includeFields(params),
|
|
355
|
+
raw.map((row) => fromStorage(plan.columns, row)),
|
|
356
|
+
);
|
|
329
357
|
return Response.json(rows);
|
|
330
358
|
}
|
|
331
359
|
|
|
@@ -358,7 +386,7 @@ async function insertRow(
|
|
|
358
386
|
request: Request,
|
|
359
387
|
decision: AuthorizeDecision,
|
|
360
388
|
): Promise<Response> {
|
|
361
|
-
const body =
|
|
389
|
+
const body = await readJsonBody(request);
|
|
362
390
|
if (body === null) {
|
|
363
391
|
return fail(400, "Body must be JSON");
|
|
364
392
|
}
|
|
@@ -374,8 +402,9 @@ async function insertRow(
|
|
|
374
402
|
Object.assign(value, decision.setColumns ?? {});
|
|
375
403
|
|
|
376
404
|
const { row, junctionValues } = splitRow(plan, kind, value);
|
|
377
|
-
|
|
378
|
-
|
|
405
|
+
const uuid = plan.idType === "uuid" ? newRowId("uuid") : undefined;
|
|
406
|
+
if (uuid !== undefined) {
|
|
407
|
+
row.id = uuid;
|
|
379
408
|
}
|
|
380
409
|
if (plan.timestamps) {
|
|
381
410
|
const now = new Date().toISOString();
|
|
@@ -385,6 +414,8 @@ async function insertRow(
|
|
|
385
414
|
|
|
386
415
|
const inserted = await db.insertInto(plan.table).values(row).returningAll().executeTakeFirst();
|
|
387
416
|
const stored = inserted ?? row;
|
|
417
|
+
/* SAFETY: `id` is the primary key (idDataType: uuid text or an integer). Every supported dialect
|
|
418
|
+
returns the inserted row via RETURNING, and the uuid fallback row carries the id set above. */
|
|
388
419
|
const rowId = stored.id as string | number;
|
|
389
420
|
await writeJunctions(db, plan, rowId, junctionValues);
|
|
390
421
|
|
|
@@ -401,11 +432,11 @@ async function updateRow(
|
|
|
401
432
|
decision: AuthorizeDecision,
|
|
402
433
|
id: string | number,
|
|
403
434
|
): Promise<Response> {
|
|
404
|
-
const body =
|
|
435
|
+
const body = await readJsonBody(request);
|
|
405
436
|
if (body === null) {
|
|
406
437
|
return fail(400, "Body must be JSON");
|
|
407
438
|
}
|
|
408
|
-
if (
|
|
439
|
+
if (isJsonObject(body) && Object.keys(body).length === 0) {
|
|
409
440
|
return fail(400, "Empty patch");
|
|
410
441
|
}
|
|
411
442
|
const { valid, errors, value } = validateRow(tableDef, body, { partial: true });
|
|
@@ -419,7 +450,7 @@ async function updateRow(
|
|
|
419
450
|
row.updated_at = new Date().toISOString();
|
|
420
451
|
}
|
|
421
452
|
|
|
422
|
-
let stored:
|
|
453
|
+
let stored: DynamicRow | undefined;
|
|
423
454
|
if (Object.keys(row).length > 0) {
|
|
424
455
|
let uq = db.updateTable(plan.table).set(row).where("id", "=", id);
|
|
425
456
|
uq = applyOwner(uq, decision, plan.columns);
|
|
@@ -447,7 +478,7 @@ async function deleteRow(
|
|
|
447
478
|
let dq = db.deleteFrom(plan.table).where("id", "=", id);
|
|
448
479
|
dq = applyOwner(dq, decision, plan.columns);
|
|
449
480
|
const result = await dq.executeTakeFirst();
|
|
450
|
-
if (
|
|
481
|
+
if (result.numDeletedRows === 0n) {
|
|
451
482
|
return fail(404, "Not found");
|
|
452
483
|
}
|
|
453
484
|
// Junction rows referencing the deleted row are cleaned per junction table.
|
|
@@ -460,13 +491,9 @@ async function deleteRow(
|
|
|
460
491
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
461
492
|
|
|
462
493
|
/** Split a validated payload into storage columns and junction (to-many table ref) values. */
|
|
463
|
-
function splitRow(
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
value: Record<string, unknown>,
|
|
467
|
-
): { row: Record<string, unknown>; junctionValues: Record<string, unknown> } {
|
|
468
|
-
const row: Record<string, unknown> = {};
|
|
469
|
-
const junctionValues: Record<string, unknown> = {};
|
|
494
|
+
function splitRow(plan: TablePlan, kind: SqlDialectKind, value: JsonObject): SplitRow {
|
|
495
|
+
const row: JsonObject = {};
|
|
496
|
+
const junctionValues: JsonObject = {};
|
|
470
497
|
const junctionFields = new Set(plan.junctions.map((j) => j.field));
|
|
471
498
|
for (const [field, raw] of Object.entries(value)) {
|
|
472
499
|
if (junctionFields.has(field)) {
|
|
@@ -489,7 +516,7 @@ async function writeJunctions(
|
|
|
489
516
|
db: Kysely<DynamicDatabase>,
|
|
490
517
|
plan: TablePlan,
|
|
491
518
|
rowId: string | number,
|
|
492
|
-
junctionValues:
|
|
519
|
+
junctionValues: JsonObject,
|
|
493
520
|
): Promise<void> {
|
|
494
521
|
for (const junction of plan.junctions) {
|
|
495
522
|
const value = junctionValues[junction.field];
|
|
@@ -497,7 +524,7 @@ async function writeJunctions(
|
|
|
497
524
|
continue;
|
|
498
525
|
}
|
|
499
526
|
await db.deleteFrom(junction.table).where(junction.sourceColumn, "=", rowId).execute();
|
|
500
|
-
const ids
|
|
527
|
+
const ids = Array.isArray(value) ? value : [];
|
|
501
528
|
for (const targetId of ids) {
|
|
502
529
|
await db
|
|
503
530
|
.insertInto(junction.table)
|
|
@@ -514,17 +541,29 @@ async function expandIncludes(
|
|
|
514
541
|
tables: Record<string, TableDef>,
|
|
515
542
|
kind: SqlDialectKind,
|
|
516
543
|
fields: string[],
|
|
517
|
-
rows:
|
|
518
|
-
): Promise<
|
|
544
|
+
rows: DynamicRow[],
|
|
545
|
+
): Promise<PayloadRow[]> {
|
|
519
546
|
if (fields.length === 0 || rows.length === 0) {
|
|
520
547
|
return rows;
|
|
521
548
|
}
|
|
549
|
+
// Expansions land on copies, so every lookup below reads the stored columns of `rows`.
|
|
550
|
+
const expanded = rows.map((stored) => {
|
|
551
|
+
const payload: PayloadRow = { ...stored };
|
|
552
|
+
return { payload, stored };
|
|
553
|
+
});
|
|
522
554
|
for (const field of fields) {
|
|
523
555
|
const spec = plan.columns.find((c) => c.field === field && c.ref !== null && !c.manyRef);
|
|
524
556
|
if (spec && spec.ref!.section === "data" && tables[spec.ref!.name]) {
|
|
525
557
|
const targetDef = tables[spec.ref!.name]!;
|
|
526
558
|
const targetPlan = planTable(spec.ref!.name, targetDef, tables, kind);
|
|
527
|
-
const ids = [
|
|
559
|
+
const ids = [
|
|
560
|
+
...new Set(
|
|
561
|
+
rows.flatMap((r) => {
|
|
562
|
+
const fk = r[spec.column];
|
|
563
|
+
return fk == null ? [] : [fk];
|
|
564
|
+
}),
|
|
565
|
+
),
|
|
566
|
+
];
|
|
528
567
|
if (ids.length === 0) {
|
|
529
568
|
continue;
|
|
530
569
|
}
|
|
@@ -533,12 +572,10 @@ async function expandIncludes(
|
|
|
533
572
|
.selectAll()
|
|
534
573
|
.where("id", "in", ids)
|
|
535
574
|
.execute();
|
|
536
|
-
const byId = new Map(
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const key = row[spec.column];
|
|
541
|
-
row[field] = key == null ? null : (byId.get(key as string | number) ?? null);
|
|
575
|
+
const byId = new Map(targets.map((t) => [t.id, fromStorage(targetPlan.columns, t)]));
|
|
576
|
+
for (const { payload, stored } of expanded) {
|
|
577
|
+
const key = stored[spec.column];
|
|
578
|
+
payload[field] = key == null ? null : (byId.get(key) ?? null);
|
|
542
579
|
}
|
|
543
580
|
continue;
|
|
544
581
|
}
|
|
@@ -551,40 +588,55 @@ async function expandIncludes(
|
|
|
551
588
|
tables,
|
|
552
589
|
kind,
|
|
553
590
|
);
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
const rowIds = rows.
|
|
591
|
+
/* Every row here came off this table's own SELECT, so its `id` (the primary key) is always
|
|
592
|
+
present and never null, unlike the optional FK column read above. */
|
|
593
|
+
const rowIds = rows.flatMap((r) => (r.id === undefined ? [] : [r.id]));
|
|
557
594
|
const links = await db
|
|
558
595
|
.selectFrom(junction.table)
|
|
559
596
|
.selectAll()
|
|
560
597
|
.where(junction.sourceColumn, "in", rowIds)
|
|
561
598
|
.execute();
|
|
562
|
-
|
|
599
|
+
// SELECT * returns every junction column, so a link's target column is always present.
|
|
600
|
+
const targetIds = [
|
|
601
|
+
...new Set(
|
|
602
|
+
links.flatMap((l) => {
|
|
603
|
+
const targetId = l[junction.targetColumn];
|
|
604
|
+
return targetId === undefined ? [] : [targetId];
|
|
605
|
+
}),
|
|
606
|
+
),
|
|
607
|
+
];
|
|
563
608
|
const targets =
|
|
564
609
|
targetIds.length > 0
|
|
565
610
|
? await db.selectFrom(targetPlan.table).selectAll().where("id", "in", targetIds).execute()
|
|
566
611
|
: [];
|
|
567
|
-
const byId = new Map(
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
.filter((t) => t !== undefined);
|
|
612
|
+
const byId = new Map(targets.map((t) => [t.id, fromStorage(targetPlan.columns, t)]));
|
|
613
|
+
for (const { payload, stored } of expanded) {
|
|
614
|
+
const mine = links.filter((l) => l[junction.sourceColumn] === stored.id);
|
|
615
|
+
payload[field] = mine.flatMap((l) => {
|
|
616
|
+
const target = byId.get(l[junction.targetColumn]);
|
|
617
|
+
return target === undefined ? [] : [target];
|
|
618
|
+
});
|
|
575
619
|
}
|
|
576
620
|
}
|
|
577
621
|
}
|
|
578
|
-
return
|
|
622
|
+
return expanded.map(({ payload }) => payload);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Read a request body as JSON. `Body.json()` is `JSON.parse` of the body text, so the result is a
|
|
627
|
+
* JSON value; an absent or malformed body reads as null, the same as a literal `null` body.
|
|
628
|
+
*/
|
|
629
|
+
function readJsonBody(request: Request): Promise<JsonValue> {
|
|
630
|
+
return request.json().catch(() => null);
|
|
579
631
|
}
|
|
580
632
|
|
|
581
633
|
/** Parse a JSON query param, throwing a wire-friendly error on malformed input. */
|
|
582
|
-
function parseJsonParam(raw: string | null):
|
|
634
|
+
function parseJsonParam(raw: string | null): JsonValue {
|
|
583
635
|
if (raw === null || raw === "") {
|
|
584
636
|
return null;
|
|
585
637
|
}
|
|
586
638
|
try {
|
|
587
|
-
return JSON.parse(raw)
|
|
639
|
+
return JSON.parse(raw);
|
|
588
640
|
} catch (error) {
|
|
589
641
|
throw new Error(`Malformed JSON query parameter: ${raw}`, { cause: error });
|
|
590
642
|
}
|