@jxsuite/connector 0.1.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 +78 -0
- package/jx-extension.json +20 -0
- package/package.json +72 -0
- package/schemas/project.fragment.schema.json +122 -0
- package/src/Connections.class.json +29 -0
- package/src/D1.class.json +94 -0
- package/src/Data.class.json +106 -0
- package/src/Sqlite.class.json +81 -0
- package/src/Supabase.class.json +81 -0
- package/src/TableDelete.class.json +56 -0
- package/src/TableEntry.class.json +65 -0
- package/src/TableInsert.class.json +59 -0
- package/src/TableQuery.class.json +75 -0
- package/src/TableUpdate.class.json +65 -0
- package/src/columns.ts +292 -0
- package/src/connectors.ts +65 -0
- package/src/d1.ts +64 -0
- package/src/ddl.ts +201 -0
- package/src/dialects/bun-sqlite.ts +130 -0
- package/src/dialects/d1-http.ts +113 -0
- package/src/provider-utils.ts +86 -0
- package/src/query.ts +199 -0
- package/src/sqlite.ts +51 -0
- package/src/supabase.ts +81 -0
- package/src/table-actions.ts +156 -0
- package/src/table-delete.ts +28 -0
- package/src/table-insert.ts +29 -0
- package/src/table-node.ts +111 -0
- package/src/table-shared.ts +131 -0
- package/src/table-state.ts +134 -0
- package/src/table-update.ts +29 -0
- package/src/types.ts +139 -0
- package/src/validate.ts +193 -0
- package/src/worker.ts +612 -0
package/src/worker.ts
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker — the framework-free /_jx/data mount (specs/extensions.md §11).
|
|
3
|
+
*
|
|
4
|
+
* `Data.mount(options, ctx)` returns a plain fetch-style handler implementing the canonical wire
|
|
5
|
+
* contract:
|
|
6
|
+
*
|
|
7
|
+
* GET /_jx/data/:table ?filter=<json>&sort=<json>&limit=&offset=&include=
|
|
8
|
+
* GET /_jx/data/:table/:id
|
|
9
|
+
* POST /_jx/data/:table
|
|
10
|
+
* PATCH /_jx/data/:table/:id
|
|
11
|
+
* DELETE /_jx/data/:table/:id
|
|
12
|
+
*
|
|
13
|
+
* Authorization is fail-closed: `public` rules pass without auth, `none` always denies, and every
|
|
14
|
+
* other rule requires `ctx.auth` (the auth mount, order 10) — absent auth means 401. Grants may
|
|
15
|
+
* carry `setColumns` (forced payload columns, e.g. the owner id) and `whereOwner` (row scoping) per
|
|
16
|
+
* the connector types contract. Kysely instances are memoized per connection inside the mount
|
|
17
|
+
* closure — one per isolate, since the generated worker mounts at module init. Runs on Workers,
|
|
18
|
+
* Bun, and node: no framework, no node imports, no `new Function`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Kysely } from "kysely";
|
|
22
|
+
import { fromStorage, newRowId, planTable, toStorage } from "./columns.ts";
|
|
23
|
+
import { syncTables } from "./ddl.ts";
|
|
24
|
+
import { applyFilter, applySort, columnForField, normalizeFilter, normalizeSort } from "./query.ts";
|
|
25
|
+
import { tablesForConnection } from "./provider-utils.ts";
|
|
26
|
+
import { validateRow } from "./validate.ts";
|
|
27
|
+
import type { Dialect } from "kysely";
|
|
28
|
+
import type { ColumnSpec, SqlDialectKind, TablePlan } from "./columns.ts";
|
|
29
|
+
import type { DynamicDatabase } from "./query.ts";
|
|
30
|
+
import type {
|
|
31
|
+
AuthorizeDecision,
|
|
32
|
+
ConnectionDef,
|
|
33
|
+
JxServerContext,
|
|
34
|
+
PermissionAction,
|
|
35
|
+
TableDef,
|
|
36
|
+
} from "./types.ts";
|
|
37
|
+
import { DEFAULT_PERMISSIONS } from "./types.ts";
|
|
38
|
+
|
|
39
|
+
/** The provider surface the data mount needs (a slice of ConnectorProvider). */
|
|
40
|
+
export interface DataMountProvider {
|
|
41
|
+
kind: SqlDialectKind;
|
|
42
|
+
dialect: (connection: ConnectionDef, env: Record<string, unknown>) => Dialect | Promise<Dialect>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DataMountOptions {
|
|
46
|
+
/** Route subtree this mount owns. Defaults to "/_jx/data". */
|
|
47
|
+
basePath?: string;
|
|
48
|
+
/** Extension-contributed project sections (needs `data` and `connections`). */
|
|
49
|
+
sections: {
|
|
50
|
+
data?: Record<string, TableDef>;
|
|
51
|
+
connections?: Record<string, ConnectionDef>;
|
|
52
|
+
[key: string]: unknown;
|
|
53
|
+
};
|
|
54
|
+
/** Provider implementations keyed by `connector.provider` id. */
|
|
55
|
+
connectors: Record<string, DataMountProvider>;
|
|
56
|
+
/** Additively sync each connection's tables on first touch (dev server / local sqlite). */
|
|
57
|
+
autoSync?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Per-isolate mount state: memoized Kysely instances and first-touch sync promises. */
|
|
61
|
+
export interface DataMountState {
|
|
62
|
+
dbs: Map<string, Promise<{ db: Kysely<DynamicDatabase>; kind: SqlDialectKind }>>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Create fresh mount state (exported for hosts that dispatch without `Data.mount`). */
|
|
66
|
+
export function createDataMountState(): DataMountState {
|
|
67
|
+
return { dbs: new Map() };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type Env = Record<string, unknown>;
|
|
71
|
+
|
|
72
|
+
/** JSON error response helper. */
|
|
73
|
+
function fail(status: number, error: string, issues?: unknown): Response {
|
|
74
|
+
return Response.json({ error, ...(issues === undefined ? {} : { issues }) }, { status });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Handle one /_jx/data request.
|
|
79
|
+
*
|
|
80
|
+
* @param {Request} request
|
|
81
|
+
* @param {Env} env - Worker env (bindings + vars)
|
|
82
|
+
* @param {DataMountOptions} options
|
|
83
|
+
* @param {JxServerContext} ctx - Shared mount context (auth hooks live here)
|
|
84
|
+
* @param {DataMountState} [state] - Memoized connections; omit for a throwaway state
|
|
85
|
+
* @returns {Promise<Response>}
|
|
86
|
+
*/
|
|
87
|
+
export async function handleDataRequest(
|
|
88
|
+
request: Request,
|
|
89
|
+
env: Env,
|
|
90
|
+
options: DataMountOptions,
|
|
91
|
+
ctx: JxServerContext,
|
|
92
|
+
state: DataMountState = createDataMountState(),
|
|
93
|
+
): Promise<Response> {
|
|
94
|
+
const url = new URL(request.url);
|
|
95
|
+
const basePath = options.basePath ?? "/_jx/data";
|
|
96
|
+
if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
|
|
97
|
+
return fail(404, "Not found");
|
|
98
|
+
}
|
|
99
|
+
const segments = url.pathname.slice(basePath.length).split("/").filter(Boolean);
|
|
100
|
+
if (segments.length === 0 || segments.length > 2) {
|
|
101
|
+
return fail(404, "Not found");
|
|
102
|
+
}
|
|
103
|
+
const [tableName, rawId] = segments as [string, string?];
|
|
104
|
+
|
|
105
|
+
const tables = options.sections.data ?? {};
|
|
106
|
+
const tableDef = tables[tableName];
|
|
107
|
+
if (!tableDef) {
|
|
108
|
+
return fail(404, `Unknown table "${tableName}"`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const { method } = request;
|
|
112
|
+
let action: PermissionAction;
|
|
113
|
+
if (method === "GET") {
|
|
114
|
+
action = "read";
|
|
115
|
+
} else if (method === "POST" && rawId === undefined) {
|
|
116
|
+
action = "insert";
|
|
117
|
+
} else if (method === "PATCH" && rawId !== undefined) {
|
|
118
|
+
action = "update";
|
|
119
|
+
} else if (method === "DELETE" && rawId !== undefined) {
|
|
120
|
+
action = "delete";
|
|
121
|
+
} else {
|
|
122
|
+
return fail(405, "Method not allowed");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const authorized = await authorize(request, env, ctx, tableName, tableDef, action);
|
|
126
|
+
if (authorized instanceof Response) {
|
|
127
|
+
return authorized;
|
|
128
|
+
}
|
|
129
|
+
const { decision } = authorized;
|
|
130
|
+
|
|
131
|
+
let db: Kysely<DynamicDatabase>;
|
|
132
|
+
let kind: SqlDialectKind;
|
|
133
|
+
try {
|
|
134
|
+
({ db, kind } = await getDb(state, options, env, tableDef.connection));
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
137
|
+
return fail(500, `Connection error: ${message}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const plan = planTable(tableName, tableDef, tables, kind);
|
|
141
|
+
const id = rawId === undefined ? undefined : parseId(plan, rawId);
|
|
142
|
+
if (rawId !== undefined && id === undefined) {
|
|
143
|
+
return fail(404, "Not found");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
switch (action) {
|
|
148
|
+
case "read": {
|
|
149
|
+
return id === undefined
|
|
150
|
+
? await listRows(db, plan, tables, kind, url, decision)
|
|
151
|
+
: await getRow(db, plan, tables, kind, url, decision, id);
|
|
152
|
+
}
|
|
153
|
+
case "insert": {
|
|
154
|
+
return await insertRow(db, plan, tableDef, kind, request, decision);
|
|
155
|
+
}
|
|
156
|
+
case "update": {
|
|
157
|
+
return await updateRow(db, plan, tableDef, kind, request, decision, id!);
|
|
158
|
+
}
|
|
159
|
+
default: {
|
|
160
|
+
return await deleteRow(db, plan, decision, id!);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
165
|
+
return fail(500, message);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The mount + section-owner class for the `data` section. */
|
|
170
|
+
export const Data = {
|
|
171
|
+
/** Static `mount` capability (specs/extensions.md §11): one shared handler per isolate. */
|
|
172
|
+
mount(options: DataMountOptions, ctx: JxServerContext) {
|
|
173
|
+
const state = createDataMountState();
|
|
174
|
+
return (request: Request, env: Env): Promise<Response> =>
|
|
175
|
+
handleDataRequest(request, env, options, ctx, state);
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
/** 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 };
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// ─── Authorization ────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Resolve the rule for an action and evaluate it — fail-closed without `ctx.auth`.
|
|
189
|
+
*
|
|
190
|
+
* @returns {Promise<Response | { decision: AuthorizeDecision }>} An error response, or the grant
|
|
191
|
+
*/
|
|
192
|
+
async function authorize(
|
|
193
|
+
request: Request,
|
|
194
|
+
env: Env,
|
|
195
|
+
ctx: JxServerContext,
|
|
196
|
+
table: string,
|
|
197
|
+
tableDef: TableDef,
|
|
198
|
+
action: PermissionAction,
|
|
199
|
+
): Promise<Response | { decision: AuthorizeDecision }> {
|
|
200
|
+
const rule = tableDef.permissions?.[action] ?? DEFAULT_PERMISSIONS[action];
|
|
201
|
+
if (rule === "public") {
|
|
202
|
+
return { decision: { allow: true } };
|
|
203
|
+
}
|
|
204
|
+
if (rule === "none") {
|
|
205
|
+
return fail(403, `${action} is disabled for "${table}"`);
|
|
206
|
+
}
|
|
207
|
+
const { auth } = ctx;
|
|
208
|
+
if (!auth) {
|
|
209
|
+
// Fail-closed rule (specs/extensions.md §11): without an auth mount, only "public" passes.
|
|
210
|
+
return fail(401, `${action} on "${table}" requires authentication (no auth mount configured)`);
|
|
211
|
+
}
|
|
212
|
+
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
|
+
);
|
|
223
|
+
if (!decision.allow) {
|
|
224
|
+
const status = decision.status ?? (session ? 403 : 401);
|
|
225
|
+
return fail(status, decision.error ?? "Forbidden");
|
|
226
|
+
}
|
|
227
|
+
return { decision };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ─── Connections ──────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
/** Open (or reuse) the Kysely instance for a connection, syncing on first touch if configured. */
|
|
233
|
+
function getDb(
|
|
234
|
+
state: DataMountState,
|
|
235
|
+
options: DataMountOptions,
|
|
236
|
+
env: Env,
|
|
237
|
+
connectionName: string,
|
|
238
|
+
): Promise<{ db: Kysely<DynamicDatabase>; kind: SqlDialectKind }> {
|
|
239
|
+
const memo = state.dbs.get(connectionName);
|
|
240
|
+
if (memo) {
|
|
241
|
+
return memo;
|
|
242
|
+
}
|
|
243
|
+
const open = (async () => {
|
|
244
|
+
const connections = options.sections.connections ?? {};
|
|
245
|
+
const connection = connections[connectionName];
|
|
246
|
+
if (!connection) {
|
|
247
|
+
throw new Error(`table names unknown connection "${connectionName}"`);
|
|
248
|
+
}
|
|
249
|
+
const provider = options.connectors[connection.provider];
|
|
250
|
+
if (!provider) {
|
|
251
|
+
throw new Error(`no connector provider "${connection.provider}" is available`);
|
|
252
|
+
}
|
|
253
|
+
const dialect = await provider.dialect({ ...connection, $name: connectionName }, env);
|
|
254
|
+
const db = new Kysely<DynamicDatabase>({ dialect });
|
|
255
|
+
if (options.autoSync) {
|
|
256
|
+
const tables = tablesForConnection(options.sections.data ?? {}, connectionName);
|
|
257
|
+
await syncTables(db, tables, { dialect: provider.kind });
|
|
258
|
+
}
|
|
259
|
+
return { db, kind: provider.kind };
|
|
260
|
+
})();
|
|
261
|
+
state.dbs.set(connectionName, open);
|
|
262
|
+
open.catch(() => state.dbs.delete(connectionName));
|
|
263
|
+
return open;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ─── Actions ──────────────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
/** Parse a path id per the table's id strategy (integer ids must be integral). */
|
|
269
|
+
function parseId(plan: TablePlan, rawId: string): string | number | undefined {
|
|
270
|
+
if (plan.idType === "integer") {
|
|
271
|
+
const num = Number(rawId);
|
|
272
|
+
return Number.isInteger(num) ? num : undefined;
|
|
273
|
+
}
|
|
274
|
+
return rawId;
|
|
275
|
+
}
|
|
276
|
+
|
|
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 }>(
|
|
279
|
+
qb: T,
|
|
280
|
+
decision: AuthorizeDecision,
|
|
281
|
+
specs: ColumnSpec[],
|
|
282
|
+
): T {
|
|
283
|
+
const owner = decision.whereOwner;
|
|
284
|
+
if (!owner) {
|
|
285
|
+
return qb;
|
|
286
|
+
}
|
|
287
|
+
const column = columnForField(owner.field, specs);
|
|
288
|
+
return qb.where(column as never, "=" as never, owner.value as never);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function listRows(
|
|
292
|
+
db: Kysely<DynamicDatabase>,
|
|
293
|
+
plan: TablePlan,
|
|
294
|
+
tables: Record<string, TableDef>,
|
|
295
|
+
kind: SqlDialectKind,
|
|
296
|
+
url: URL,
|
|
297
|
+
decision: AuthorizeDecision,
|
|
298
|
+
): Promise<Response> {
|
|
299
|
+
const params = url.searchParams;
|
|
300
|
+
let filter: unknown = null;
|
|
301
|
+
let sort: unknown = null;
|
|
302
|
+
try {
|
|
303
|
+
filter = parseJsonParam(params.get("filter"));
|
|
304
|
+
sort = parseJsonParam(params.get("sort"));
|
|
305
|
+
} catch (error) {
|
|
306
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
307
|
+
return fail(400, message);
|
|
308
|
+
}
|
|
309
|
+
const limit = positiveInt(params.get("limit"));
|
|
310
|
+
const offset = positiveInt(params.get("offset"));
|
|
311
|
+
|
|
312
|
+
let qb = db.selectFrom(plan.table).selectAll();
|
|
313
|
+
qb = applyOwner(qb, decision, plan.columns);
|
|
314
|
+
qb = applyFilter(qb, normalizeFilter(filter), plan.columns);
|
|
315
|
+
qb = applySort(qb, normalizeSort(sort), plan.columns);
|
|
316
|
+
if (limit !== undefined) {
|
|
317
|
+
qb = qb.limit(limit);
|
|
318
|
+
} else if (offset !== undefined) {
|
|
319
|
+
// SQL needs a LIMIT for OFFSET to apply; a page this size is effectively "no limit".
|
|
320
|
+
qb = qb.limit(100_000_000);
|
|
321
|
+
}
|
|
322
|
+
if (offset !== undefined) {
|
|
323
|
+
qb = qb.offset(offset);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
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);
|
|
329
|
+
return Response.json(rows);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function getRow(
|
|
333
|
+
db: Kysely<DynamicDatabase>,
|
|
334
|
+
plan: TablePlan,
|
|
335
|
+
tables: Record<string, TableDef>,
|
|
336
|
+
kind: SqlDialectKind,
|
|
337
|
+
url: URL,
|
|
338
|
+
decision: AuthorizeDecision,
|
|
339
|
+
id: string | number,
|
|
340
|
+
): Promise<Response> {
|
|
341
|
+
let qb = db.selectFrom(plan.table).selectAll().where("id", "=", id);
|
|
342
|
+
qb = applyOwner(qb, decision, plan.columns);
|
|
343
|
+
const raw = await qb.executeTakeFirst();
|
|
344
|
+
if (!raw) {
|
|
345
|
+
return fail(404, "Not found");
|
|
346
|
+
}
|
|
347
|
+
const rows = await expandIncludes(db, plan, tables, kind, includeFields(url.searchParams), [
|
|
348
|
+
fromStorage(plan.columns, raw),
|
|
349
|
+
]);
|
|
350
|
+
return Response.json(rows[0]);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function insertRow(
|
|
354
|
+
db: Kysely<DynamicDatabase>,
|
|
355
|
+
plan: TablePlan,
|
|
356
|
+
tableDef: TableDef,
|
|
357
|
+
kind: SqlDialectKind,
|
|
358
|
+
request: Request,
|
|
359
|
+
decision: AuthorizeDecision,
|
|
360
|
+
): Promise<Response> {
|
|
361
|
+
const body = (await request.json().catch(() => null)) as unknown;
|
|
362
|
+
if (body === null) {
|
|
363
|
+
return fail(400, "Body must be JSON");
|
|
364
|
+
}
|
|
365
|
+
const { valid, errors, value } = validateRow(tableDef, body);
|
|
366
|
+
if (!valid) {
|
|
367
|
+
return fail(400, "Validation failed", errors);
|
|
368
|
+
}
|
|
369
|
+
for (const [field, schema] of Object.entries(tableDef.schema.properties ?? {})) {
|
|
370
|
+
if (value[field] === undefined && schema.default !== undefined) {
|
|
371
|
+
value[field] = schema.default;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
Object.assign(value, decision.setColumns ?? {});
|
|
375
|
+
|
|
376
|
+
const { row, junctionValues } = splitRow(plan, kind, value);
|
|
377
|
+
if (plan.idType === "uuid") {
|
|
378
|
+
row.id = newRowId("uuid");
|
|
379
|
+
}
|
|
380
|
+
if (plan.timestamps) {
|
|
381
|
+
const now = new Date().toISOString();
|
|
382
|
+
row.created_at = now;
|
|
383
|
+
row.updated_at = now;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const inserted = await db.insertInto(plan.table).values(row).returningAll().executeTakeFirst();
|
|
387
|
+
const stored = inserted ?? row;
|
|
388
|
+
const rowId = stored.id as string | number;
|
|
389
|
+
await writeJunctions(db, plan, rowId, junctionValues);
|
|
390
|
+
|
|
391
|
+
const result = { ...fromStorage(plan.columns, stored), ...junctionValues };
|
|
392
|
+
return Response.json(result, { status: 201 });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function updateRow(
|
|
396
|
+
db: Kysely<DynamicDatabase>,
|
|
397
|
+
plan: TablePlan,
|
|
398
|
+
tableDef: TableDef,
|
|
399
|
+
kind: SqlDialectKind,
|
|
400
|
+
request: Request,
|
|
401
|
+
decision: AuthorizeDecision,
|
|
402
|
+
id: string | number,
|
|
403
|
+
): Promise<Response> {
|
|
404
|
+
const body = (await request.json().catch(() => null)) as unknown;
|
|
405
|
+
if (body === null) {
|
|
406
|
+
return fail(400, "Body must be JSON");
|
|
407
|
+
}
|
|
408
|
+
if (typeof body === "object" && !Array.isArray(body) && Object.keys(body).length === 0) {
|
|
409
|
+
return fail(400, "Empty patch");
|
|
410
|
+
}
|
|
411
|
+
const { valid, errors, value } = validateRow(tableDef, body, { partial: true });
|
|
412
|
+
if (!valid) {
|
|
413
|
+
return fail(400, "Validation failed", errors);
|
|
414
|
+
}
|
|
415
|
+
Object.assign(value, decision.setColumns ?? {});
|
|
416
|
+
|
|
417
|
+
const { row, junctionValues } = splitRow(plan, kind, value);
|
|
418
|
+
if (plan.timestamps) {
|
|
419
|
+
row.updated_at = new Date().toISOString();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
let stored: Record<string, unknown> | undefined;
|
|
423
|
+
if (Object.keys(row).length > 0) {
|
|
424
|
+
let uq = db.updateTable(plan.table).set(row).where("id", "=", id);
|
|
425
|
+
uq = applyOwner(uq, decision, plan.columns);
|
|
426
|
+
stored = await uq.returningAll().executeTakeFirst();
|
|
427
|
+
} else {
|
|
428
|
+
let sq = db.selectFrom(plan.table).selectAll().where("id", "=", id);
|
|
429
|
+
sq = applyOwner(sq, decision, plan.columns);
|
|
430
|
+
stored = await sq.executeTakeFirst();
|
|
431
|
+
}
|
|
432
|
+
if (!stored) {
|
|
433
|
+
return fail(404, "Not found");
|
|
434
|
+
}
|
|
435
|
+
await writeJunctions(db, plan, id, junctionValues);
|
|
436
|
+
|
|
437
|
+
const result = { ...fromStorage(plan.columns, stored), ...junctionValues };
|
|
438
|
+
return Response.json(result);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function deleteRow(
|
|
442
|
+
db: Kysely<DynamicDatabase>,
|
|
443
|
+
plan: TablePlan,
|
|
444
|
+
decision: AuthorizeDecision,
|
|
445
|
+
id: string | number,
|
|
446
|
+
): Promise<Response> {
|
|
447
|
+
let dq = db.deleteFrom(plan.table).where("id", "=", id);
|
|
448
|
+
dq = applyOwner(dq, decision, plan.columns);
|
|
449
|
+
const result = await dq.executeTakeFirst();
|
|
450
|
+
if ((result.numDeletedRows ?? 0n) === 0n) {
|
|
451
|
+
return fail(404, "Not found");
|
|
452
|
+
}
|
|
453
|
+
// Junction rows referencing the deleted row are cleaned per junction table.
|
|
454
|
+
for (const junction of plan.junctions) {
|
|
455
|
+
await db.deleteFrom(junction.table).where(junction.sourceColumn, "=", id).execute();
|
|
456
|
+
}
|
|
457
|
+
return Response.json({ ok: true });
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
/** 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> = {};
|
|
470
|
+
const junctionFields = new Set(plan.junctions.map((j) => j.field));
|
|
471
|
+
for (const [field, raw] of Object.entries(value)) {
|
|
472
|
+
if (junctionFields.has(field)) {
|
|
473
|
+
junctionValues[field] = raw;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
const spec = plan.columns.find((c) => c.field === field);
|
|
477
|
+
if (spec) {
|
|
478
|
+
row[spec.column] = toStorage(spec, raw, kind);
|
|
479
|
+
} else {
|
|
480
|
+
// Grant-forced columns (setColumns) may target columns outside the schema.
|
|
481
|
+
row[field] = raw;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return { junctionValues, row };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Replace the junction rows for the given source row (delete + insert). */
|
|
488
|
+
async function writeJunctions(
|
|
489
|
+
db: Kysely<DynamicDatabase>,
|
|
490
|
+
plan: TablePlan,
|
|
491
|
+
rowId: string | number,
|
|
492
|
+
junctionValues: Record<string, unknown>,
|
|
493
|
+
): Promise<void> {
|
|
494
|
+
for (const junction of plan.junctions) {
|
|
495
|
+
const value = junctionValues[junction.field];
|
|
496
|
+
if (value === undefined) {
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
await db.deleteFrom(junction.table).where(junction.sourceColumn, "=", rowId).execute();
|
|
500
|
+
const ids: unknown[] = Array.isArray(value) ? (value as unknown[]) : [];
|
|
501
|
+
for (const targetId of ids) {
|
|
502
|
+
await db
|
|
503
|
+
.insertInto(junction.table)
|
|
504
|
+
.values({ [junction.sourceColumn]: rowId, [junction.targetColumn]: targetId })
|
|
505
|
+
.execute();
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Expand `?include=` fields: to-one table refs and to-many junctions (content refs are skipped). */
|
|
511
|
+
async function expandIncludes(
|
|
512
|
+
db: Kysely<DynamicDatabase>,
|
|
513
|
+
plan: TablePlan,
|
|
514
|
+
tables: Record<string, TableDef>,
|
|
515
|
+
kind: SqlDialectKind,
|
|
516
|
+
fields: string[],
|
|
517
|
+
rows: Record<string, unknown>[],
|
|
518
|
+
): Promise<Record<string, unknown>[]> {
|
|
519
|
+
if (fields.length === 0 || rows.length === 0) {
|
|
520
|
+
return rows;
|
|
521
|
+
}
|
|
522
|
+
for (const field of fields) {
|
|
523
|
+
const spec = plan.columns.find((c) => c.field === field && c.ref !== null && !c.manyRef);
|
|
524
|
+
if (spec && spec.ref!.section === "data" && tables[spec.ref!.name]) {
|
|
525
|
+
const targetDef = tables[spec.ref!.name]!;
|
|
526
|
+
const targetPlan = planTable(spec.ref!.name, targetDef, tables, kind);
|
|
527
|
+
const ids = [...new Set(rows.map((r) => r[spec.column]).filter((v) => v != null))];
|
|
528
|
+
if (ids.length === 0) {
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
const targets = await db
|
|
532
|
+
.selectFrom(targetPlan.table)
|
|
533
|
+
.selectAll()
|
|
534
|
+
.where("id", "in", ids)
|
|
535
|
+
.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);
|
|
542
|
+
}
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const junction = plan.junctions.find((j) => j.field === field);
|
|
547
|
+
if (junction && tables[junction.targetTable]) {
|
|
548
|
+
const targetPlan = planTable(
|
|
549
|
+
junction.targetTable,
|
|
550
|
+
tables[junction.targetTable]!,
|
|
551
|
+
tables,
|
|
552
|
+
kind,
|
|
553
|
+
);
|
|
554
|
+
const rowIds = rows.map((r) => r.id).filter((v) => v != null);
|
|
555
|
+
if (rowIds.length === 0) {
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
const links = await db
|
|
559
|
+
.selectFrom(junction.table)
|
|
560
|
+
.selectAll()
|
|
561
|
+
.where(junction.sourceColumn, "in", rowIds)
|
|
562
|
+
.execute();
|
|
563
|
+
const targetIds = [...new Set(links.map((l) => l[junction.targetColumn]))];
|
|
564
|
+
const targets =
|
|
565
|
+
targetIds.length > 0
|
|
566
|
+
? await db.selectFrom(targetPlan.table).selectAll().where("id", "in", targetIds).execute()
|
|
567
|
+
: [];
|
|
568
|
+
const byId = new Map(
|
|
569
|
+
targets.map((t) => [t.id as string | number, fromStorage(targetPlan.columns, t)]),
|
|
570
|
+
);
|
|
571
|
+
for (const row of rows) {
|
|
572
|
+
const mine = links.filter((l) => l[junction.sourceColumn] === row.id);
|
|
573
|
+
row[field] = mine
|
|
574
|
+
.map((l) => byId.get(l[junction.targetColumn] as string | number))
|
|
575
|
+
.filter((t) => t !== undefined);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return rows;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Parse a JSON query param, throwing a wire-friendly error on malformed input. */
|
|
583
|
+
function parseJsonParam(raw: string | null): unknown {
|
|
584
|
+
if (raw === null || raw === "") {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
try {
|
|
588
|
+
return JSON.parse(raw) as unknown;
|
|
589
|
+
} catch (error) {
|
|
590
|
+
throw new Error(`Malformed JSON query parameter: ${raw}`, { cause: error });
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Parse a non-negative integer query param. */
|
|
595
|
+
function positiveInt(raw: string | null): number | undefined {
|
|
596
|
+
if (raw === null || raw === "") {
|
|
597
|
+
return undefined;
|
|
598
|
+
}
|
|
599
|
+
const num = Number(raw);
|
|
600
|
+
return Number.isInteger(num) && num >= 0 ? num : undefined;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Parse the `include` param into field names. */
|
|
604
|
+
function includeFields(params: URLSearchParams): string[] {
|
|
605
|
+
const raw = params.get("include");
|
|
606
|
+
return raw
|
|
607
|
+
? raw
|
|
608
|
+
.split(",")
|
|
609
|
+
.map((f) => f.trim())
|
|
610
|
+
.filter(Boolean)
|
|
611
|
+
: [];
|
|
612
|
+
}
|