@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/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 { DynamicDatabase } from "./query.ts";
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: Record<string, unknown>) => Dialect | Promise<Dialect>;
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 (needs `data` and `connections`). */
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
- type Env = Record<string, unknown>;
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?: unknown): Response {
74
- return Response.json({ error, ...(issues === undefined ? {} : { issues }) }, { status });
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 {Env} env - Worker env (bindings + vars)
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: 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
- const segments = url.pathname.slice(basePath.length).split("/").filter(Boolean);
100
- if (segments.length === 0 || segments.length > 2) {
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: Env): Promise<Response> =>
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: unknown): Record<string, TableDef> {
180
- const section = (sectionValue ?? {}) as Record<string, TableDef>;
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: 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 decision = await auth.authorize(
214
- {
215
- action,
216
- rule,
217
- session,
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: 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(col, "=", value)` method. */
278
- function applyOwner<T extends { where: (col: never, op: never, value: never) => T }>(
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
- const column = columnForField(owner.field, specs);
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: unknown = null;
301
- let sort: unknown = null;
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
- let rows = raw.map((row) => fromStorage(plan.columns, row));
328
- rows = await expandIncludes(db, plan, tables, kind, includeFields(params), rows);
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 = (await request.json().catch(() => null)) as unknown;
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
- if (plan.idType === "uuid") {
378
- row.id = newRowId("uuid");
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 = (await request.json().catch(() => null)) as unknown;
435
+ const body = await readJsonBody(request);
405
436
  if (body === null) {
406
437
  return fail(400, "Body must be JSON");
407
438
  }
408
- if (typeof body === "object" && !Array.isArray(body) && Object.keys(body).length === 0) {
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: Record<string, unknown> | undefined;
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 ((result.numDeletedRows ?? 0n) === 0n) {
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
- plan: TablePlan,
465
- kind: SqlDialectKind,
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: Record<string, unknown>,
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: unknown[] = Array.isArray(value) ? (value as unknown[]) : [];
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: Record<string, unknown>[],
518
- ): Promise<Record<string, unknown>[]> {
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 = [...new Set(rows.map((r) => r[spec.column]).filter((v) => v != null))];
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
- targets.map((t) => [t.id as string | number, fromStorage(targetPlan.columns, t)]),
538
- );
539
- for (const row of rows) {
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
- // Every row here came off this table's own SELECT, so its `id` (the primary key) is never
555
- // Null — unlike `rows.map(r => r[spec.column])` above, which reads an optional FK column.
556
- const rowIds = rows.map((r) => r.id);
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
- const targetIds = [...new Set(links.map((l) => l[junction.targetColumn]))];
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
- targets.map((t) => [t.id as string | number, fromStorage(targetPlan.columns, t)]),
569
- );
570
- for (const row of rows) {
571
- const mine = links.filter((l) => l[junction.sourceColumn] === row.id);
572
- row[field] = mine
573
- .map((l) => byId.get(l[junction.targetColumn] as string | number))
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 rows;
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): unknown {
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) as unknown;
639
+ return JSON.parse(raw);
588
640
  } catch (error) {
589
641
  throw new Error(`Malformed JSON query parameter: ${raw}`, { cause: error });
590
642
  }