@jxsuite/server 0.37.0 → 1.0.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.
@@ -0,0 +1,874 @@
1
+ /**
2
+ * Data-api — the `/__studio/data/*` + `/__studio/secrets` owner console (specs/extensions.md §13).
3
+ *
4
+ * These routes are the project OWNER's admin surface over the connector extension's connections and
5
+ * tables: row CRUD here intentionally bypasses table permission rules BY DESIGN — the boundary is
6
+ * the backend's own gate (the dev server's loopback/token guard in front of `/__studio/*`; cloud
7
+ * backends must gate on collaboration permission instead). Secret VALUES never leave the backend:
8
+ * the secrets routes list env-var NAMES only and write `.dev.vars` (wrangler convention).
9
+ *
10
+ * The server is core and must never import `@jxsuite/connector` (dep-rules enforce this): every
11
+ * connector capability (dialect, deploySchema, testConnection) dispatches through the project's
12
+ * extension registry, reusing the dev server's `local: "<provider>"` stand-in rule from jx-mounts
13
+ * (specs/extensions.md §12). Only the generic Kysely surface (row CRUD + introspection) runs here.
14
+ *
15
+ * Auth-task: auth extensions contribute additional push steps here — compose their migration steps
16
+ * (kind "auth") after the connector plan inside {@link pushDataSchema}.
17
+ */
18
+
19
+ import { existsSync, readFileSync, statSync } from "node:fs";
20
+ import { isAbsolute, relative, resolve } from "node:path";
21
+ import { Kysely, sql } from "kysely";
22
+ import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
23
+ import { errorMessage } from "@jxsuite/schema/parse";
24
+ import { loadDevVars, parseDevVars } from "./dev-vars.ts";
25
+ import { resolveConnectorStandins } from "./jx-mounts.ts";
26
+ import { writeFile } from "node:fs/promises";
27
+ import type { Dialect } from "kysely";
28
+ import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
29
+ import type { FormatEntry } from "@jxsuite/schema/format-registry";
30
+ import type { ProjectConfig } from "@jxsuite/schema/types";
31
+ import type {
32
+ DataColumnMeta,
33
+ DataConnectionInfo,
34
+ DataConnectionsResponse,
35
+ DataConnectionTestResult,
36
+ DataPushRequest,
37
+ DataPushResult,
38
+ DataPushStep,
39
+ DataRowDelete,
40
+ DataRowInsert,
41
+ DataRowsQuery,
42
+ DataRowsResult,
43
+ DataRowUpdate,
44
+ SecretsListResponse,
45
+ SecretsSetRequest,
46
+ SecretsSetResponse,
47
+ } from "@jxsuite/protocol";
48
+
49
+ // ─── Local section shapes (structural — the connector owns the full types) ───
50
+
51
+ /** One `connections` entry: identifiers and env-var names only, never secrets. */
52
+ interface ConnectionDef {
53
+ provider?: string;
54
+ [key: string]: unknown;
55
+ }
56
+
57
+ /** One `data` entry (the slice this console needs). */
58
+ interface TableDef {
59
+ connection?: string;
60
+ id?: string;
61
+ timestamps?: boolean;
62
+ schema?: { properties?: Record<string, unknown> };
63
+ }
64
+
65
+ interface ProjectSections {
66
+ config: ProjectConfig;
67
+ connections: Record<string, ConnectionDef>;
68
+ tables: Record<string, TableDef>;
69
+ }
70
+
71
+ /** Loosely-typed Kysely database — dynamic tables are only known at runtime. */
72
+ type Db = Kysely<Record<string, Record<string, unknown>>>;
73
+
74
+ /** A route-mappable failure (`status` becomes the HTTP status). */
75
+ class ApiError extends Error {
76
+ status: number;
77
+ constructor(status: number, message: string, options?: { cause?: unknown }) {
78
+ super(message, options);
79
+ this.name = "ApiError";
80
+ this.status = status;
81
+ }
82
+ }
83
+
84
+ /** The shape of the connector `deploySchema` capability result. */
85
+ interface DeployResult {
86
+ statements: string[];
87
+ applied: boolean;
88
+ warnings: string[];
89
+ }
90
+
91
+ // ─── Project + registry resolution ───────────────────────────────────────────
92
+
93
+ /** Per-project registry cache, invalidated when project.json's mtime moves. */
94
+ const registryCache = new Map<string, { mtime: number; registry: Promise<ExtensionRegistry> }>();
95
+
96
+ /** Reset the registry cache (test hook). */
97
+ export function resetDataApi(): void {
98
+ registryCache.clear();
99
+ }
100
+
101
+ /** Read the project's config + connector-owned sections; 404 when there is no project.json. */
102
+ function getProject(projectRoot: string): ProjectSections {
103
+ const path = resolve(projectRoot, "project.json");
104
+ let config: ProjectConfig;
105
+ try {
106
+ config = JSON.parse(readFileSync(path, "utf8")) as ProjectConfig;
107
+ } catch (error) {
108
+ throw new ApiError(404, `No project.json under "${projectRoot}"`, { cause: error });
109
+ }
110
+ // Section keys are the connector's host wire contract (the same literals jx-mounts passes to
111
+ // The Data mount's options.sections) — JSON introspection, not a connector import.
112
+ const cfg = config as Record<string, unknown>;
113
+ const connections = (cfg.connections ?? {}) as Record<string, ConnectionDef>;
114
+ const tables = (cfg.data ?? {}) as Record<string, TableDef>;
115
+ return { config, connections, tables };
116
+ }
117
+
118
+ /** Build (or reuse) the project's extension registry, keyed on project.json's mtime. */
119
+ function getRegistry(projectRoot: string, config: ProjectConfig): Promise<ExtensionRegistry> {
120
+ const projectJsonPath = resolve(projectRoot, "project.json");
121
+ const mtime = statSync(projectJsonPath).mtimeMs;
122
+ const cached = registryCache.get(projectRoot);
123
+ if (cached && cached.mtime === mtime) {
124
+ return cached.registry;
125
+ }
126
+ const registry = buildProjectExtensionRegistry(projectRoot, config);
127
+ registryCache.set(projectRoot, { mtime, registry });
128
+ return registry;
129
+ }
130
+
131
+ /** The mount environment: process.env merged under `.dev.vars` plus JX_PROJECT_ROOT. */
132
+ function buildEnv(projectRoot: string): Record<string, unknown> {
133
+ return { ...process.env, ...loadDevVars(projectRoot), JX_PROJECT_ROOT: projectRoot };
134
+ }
135
+
136
+ /**
137
+ * The registry entry serving a provider, after the dev `local: "<provider>"` stand-in rule
138
+ * (specs/extensions.md §12) — identical to jx-mounts' resolution.
139
+ */
140
+ function providerEntry(registry: ExtensionRegistry, provider: string): FormatEntry {
141
+ const entries = registry.connectors();
142
+ const entry = entries.find((e) => e.connector!.provider === provider);
143
+ if (!entry) {
144
+ throw new ApiError(400, `No connector provider "${provider}" is available`);
145
+ }
146
+ const { local } = entry.connector!;
147
+ return typeof local === "string"
148
+ ? (entries.find((e) => e.connector!.provider === local) ?? entry)
149
+ : entry;
150
+ }
151
+
152
+ /** The connection def by name, 404 when undeclared. */
153
+ function connectionDef(project: ProjectSections, name: string): ConnectionDef {
154
+ const def = project.connections[name];
155
+ if (!def) {
156
+ throw new ApiError(404, `Unknown connection "${name}"`);
157
+ }
158
+ return def;
159
+ }
160
+
161
+ /** Tables of one connection (the data section filtered by connection name). */
162
+ function tablesFor(project: ProjectSections, connection: string): Record<string, TableDef> {
163
+ const out: Record<string, TableDef> = {};
164
+ for (const [name, def] of Object.entries(project.tables)) {
165
+ if (def.connection === connection) {
166
+ out[name] = def;
167
+ }
168
+ }
169
+ return out;
170
+ }
171
+
172
+ /**
173
+ * The connection a rows request resolves against: the declared table's connection, else the
174
+ * explicit `connection` parameter, else the first-declared connection (system tables live on it).
175
+ */
176
+ function connectionForTable(project: ProjectSections, table: string, explicit?: string): string {
177
+ const declared = project.tables[table]?.connection;
178
+ const name = declared ?? explicit ?? Object.keys(project.connections)[0];
179
+ if (!name) {
180
+ throw new ApiError(400, "The project declares no connections");
181
+ }
182
+ connectionDef(project, name);
183
+ return name;
184
+ }
185
+
186
+ /** Open a Kysely instance for a connection through the registry, run `fn`, always destroy. */
187
+ async function withDb<T>(
188
+ projectRoot: string,
189
+ project: ProjectSections,
190
+ connectionName: string,
191
+ fn: (db: Db, kind: string) => Promise<T>,
192
+ ): Promise<T> {
193
+ const def = connectionDef(project, connectionName);
194
+ const registry = await getRegistry(projectRoot, project.config);
195
+ const entry = providerEntry(registry, String(def.provider ?? ""));
196
+ const env = buildEnv(projectRoot);
197
+ const dialect = (await entry.call("dialect", { ...def, $name: connectionName }, env)) as Dialect;
198
+ const db = new Kysely<Record<string, Record<string, unknown>>>({ dialect });
199
+ try {
200
+ return await fn(db, String(entry.connector!.kind ?? "sqlite"));
201
+ } finally {
202
+ await db.destroy();
203
+ }
204
+ }
205
+
206
+ // ─── Introspection ────────────────────────────────────────────────────────────
207
+
208
+ /** Introspect one table's columns, with primary-key detection; 404 for unknown tables. */
209
+ async function tableColumns(db: Db, kind: string, table: string): Promise<DataColumnMeta[]> {
210
+ const tables = await db.introspection.getTables({ withInternalKyselyTables: false });
211
+ const meta = tables.find((t) => t.name === table);
212
+ if (!meta) {
213
+ throw new ApiError(404, `Unknown table "${table}"`);
214
+ }
215
+ const pks = await primaryKeys(db, kind, table);
216
+ return meta.columns.map((c) => {
217
+ const column: DataColumnMeta = { name: c.name, type: c.dataType };
218
+ if (pks.has(c.name)) {
219
+ column.pk = true;
220
+ }
221
+ if (c.isNullable) {
222
+ column.nullable = true;
223
+ }
224
+ return column;
225
+ });
226
+ }
227
+
228
+ /**
229
+ * Primary-key column names. SQLite exposes them via `pragma_table_info`; every other dialect falls
230
+ * back to the connector's convention (the synthesized `id` column — auth system tables use it
231
+ * too).
232
+ */
233
+ async function primaryKeys(db: Db, kind: string, table: string): Promise<Set<string>> {
234
+ if (kind === "sqlite") {
235
+ try {
236
+ const { rows } = await sql<{ name: string }>`
237
+ select name from pragma_table_info(${sql.lit(table)}) where pk > 0
238
+ `.execute(db);
239
+ if (rows.length > 0) {
240
+ return new Set(rows.map((r) => r.name));
241
+ }
242
+ } catch {
243
+ // No pragma access — fall through to the convention.
244
+ }
245
+ }
246
+ return new Set(["id"]);
247
+ }
248
+
249
+ /** Light storage coercion for admin writes, keyed on the introspected column types. */
250
+ function coerceValues(
251
+ values: Record<string, unknown>,
252
+ columns: DataColumnMeta[],
253
+ ): Record<string, unknown> {
254
+ const byName = new Map(columns.map((c) => [c.name, c]));
255
+ const out: Record<string, unknown> = {};
256
+ for (const [key, raw] of Object.entries(values)) {
257
+ const column = byName.get(key);
258
+ if (!column) {
259
+ throw new ApiError(400, `Unknown column "${key}"`);
260
+ }
261
+ const numeric = /int|real|double|numeric|decimal/i.test(column.type);
262
+ if (typeof raw === "boolean" && numeric) {
263
+ out[key] = raw ? 1 : 0;
264
+ } else if (
265
+ typeof raw === "string" &&
266
+ numeric &&
267
+ raw.trim() !== "" &&
268
+ !Number.isNaN(Number(raw))
269
+ ) {
270
+ out[key] = Number(raw);
271
+ } else if (raw !== null && typeof raw === "object") {
272
+ out[key] = JSON.stringify(raw);
273
+ } else {
274
+ out[key] = raw;
275
+ }
276
+ }
277
+ return out;
278
+ }
279
+
280
+ /** Normalize a wire pk value against the pk column's type (integer pks arrive as strings). */
281
+ function normalizePk(pk: string | number, columns: DataColumnMeta[]): string | number {
282
+ const pkMeta = columns.find((c) => c.pk);
283
+ if (typeof pk === "string" && pkMeta && /int/i.test(pkMeta.type) && /^\d+$/.test(pk)) {
284
+ return Number(pk);
285
+ }
286
+ return pk;
287
+ }
288
+
289
+ /** The pk column name (introspected; "id" by convention when nothing is flagged). */
290
+ function pkColumn(columns: DataColumnMeta[]): string {
291
+ return columns.find((c) => c.pk)?.name ?? "id";
292
+ }
293
+
294
+ // ─── Connections ──────────────────────────────────────────────────────────────
295
+
296
+ /** Env-var NAMES a connection references (keys ending in "Env") that resolve to no value. */
297
+ function missingSecretNames(def: ConnectionDef, env: Record<string, unknown>): string[] {
298
+ const missing: string[] = [];
299
+ for (const [key, value] of Object.entries(def)) {
300
+ if (key.endsWith("Env") && typeof value === "string" && value !== "" && env[value] == null) {
301
+ missing.push(value);
302
+ }
303
+ }
304
+ return missing;
305
+ }
306
+
307
+ /**
308
+ * List the project's connections with configuration state, reachable table names, and provider
309
+ * metadata from the registry descriptors. Introspection is best-effort: unreachable or
310
+ * secret-missing connections report their declared tables only.
311
+ *
312
+ * @param {string} projectRoot
313
+ * @returns {Promise<DataConnectionsResponse>}
314
+ */
315
+ export async function listDataConnections(projectRoot: string): Promise<DataConnectionsResponse> {
316
+ const project = getProject(projectRoot);
317
+ const registry = await getRegistry(projectRoot, project.config);
318
+ const entries = registry.connectors();
319
+ const env = buildEnv(projectRoot);
320
+ const names = Object.keys(project.connections);
321
+
322
+ const connections: DataConnectionInfo[] = [];
323
+ for (const [index, name] of names.entries()) {
324
+ const def = project.connections[name]!;
325
+ const provider = String(def.provider ?? "");
326
+ const entry = entries.find((e) => e.connector!.provider === provider) ?? null;
327
+ const missingSecrets = missingSecretNames(def, env);
328
+ let tables = Object.keys(tablesFor(project, name));
329
+
330
+ if (entry && missingSecrets.length === 0) {
331
+ try {
332
+ const discovered = await withDb(projectRoot, project, name, async (db) => {
333
+ const list = await db.introspection.getTables({ withInternalKyselyTables: false });
334
+ return list.map((t) => t.name);
335
+ });
336
+ tables = [...new Set([...tables, ...discovered])];
337
+ } catch {
338
+ // Unreachable connection: fall back to the declared tables.
339
+ }
340
+ }
341
+
342
+ connections.push({
343
+ configured: missingSecrets.length === 0,
344
+ connector: entry
345
+ ? {
346
+ provider,
347
+ ...(entry.connector!.kind == null ? {} : { kind: String(entry.connector!.kind) }),
348
+ ...(entry.connector!.local == null ? {} : { local: String(entry.connector!.local) }),
349
+ ...(typeof entry.classDef.description === "string"
350
+ ? { description: entry.classDef.description }
351
+ : {}),
352
+ }
353
+ : null,
354
+ isDefault: index === 0,
355
+ missingSecrets,
356
+ name,
357
+ provider,
358
+ settings: { ...def },
359
+ tables,
360
+ });
361
+ }
362
+ return { connections };
363
+ }
364
+
365
+ /**
366
+ * Probe a connection through the registry's testConnection capability (stand-in applied).
367
+ *
368
+ * @param {string} projectRoot
369
+ * @param {string} connection - Connection name from the `connections` section
370
+ * @returns {Promise<DataConnectionTestResult>}
371
+ */
372
+ export async function testDataConnection(
373
+ projectRoot: string,
374
+ connection: string,
375
+ ): Promise<DataConnectionTestResult> {
376
+ const project = getProject(projectRoot);
377
+ const def = connectionDef(project, connection);
378
+ const registry = await getRegistry(projectRoot, project.config);
379
+ const entry = providerEntry(registry, String(def.provider ?? ""));
380
+ const env = buildEnv(projectRoot);
381
+ const result = (await entry.call(
382
+ "testConnection",
383
+ { ...def, $name: connection },
384
+ env,
385
+ )) as DataConnectionTestResult;
386
+ return { ok: result.ok === true, ...(result.error === undefined ? {} : { error: result.error }) };
387
+ }
388
+
389
+ // ─── Push ─────────────────────────────────────────────────────────────────────
390
+
391
+ /** Classify one compiled DDL statement into a push-plan step. */
392
+ export function classifyStatement(
393
+ statement: string,
394
+ declaredTables: Set<string>,
395
+ connection: string,
396
+ ): DataPushStep {
397
+ const createTable = statement.match(/^create table "?([\w.]+)"?/i);
398
+ if (createTable) {
399
+ const table = createTable[1]!;
400
+ const junction = !declaredTables.has(table);
401
+ return {
402
+ connection,
403
+ kind: junction ? "junction" : "createTable",
404
+ sql: statement,
405
+ summary: junction ? `Create junction table "${table}"` : `Create table "${table}"`,
406
+ table,
407
+ };
408
+ }
409
+ const addColumn = statement.match(/^alter table "?([\w.]+)"? add column "?([\w.]+)"?/i);
410
+ if (addColumn) {
411
+ return {
412
+ connection,
413
+ kind: "addColumn",
414
+ sql: statement,
415
+ summary: `Add column "${addColumn[2]!}" to "${addColumn[1]!}"`,
416
+ table: addColumn[1]!,
417
+ };
418
+ }
419
+ const index = statement.match(/^create index "?([\w.]+)"? on "?([\w.]+)"?/i);
420
+ if (index) {
421
+ return {
422
+ connection,
423
+ kind: "index",
424
+ sql: statement,
425
+ summary: `Create index "${index[1]!}" on "${index[2]!}"`,
426
+ table: index[2]!,
427
+ };
428
+ }
429
+ return { connection, kind: "statement", sql: statement, summary: statement };
430
+ }
431
+
432
+ /**
433
+ * Push the project's table schemas through the registry's deploySchema capability — additive-only,
434
+ * with `dryRun` compiling the plan without executing it.
435
+ *
436
+ * @param {string} projectRoot
437
+ * @param {DataPushRequest} request
438
+ * @returns {Promise<DataPushResult>}
439
+ */
440
+ export async function pushDataSchema(
441
+ projectRoot: string,
442
+ request: DataPushRequest = {},
443
+ ): Promise<DataPushResult> {
444
+ const project = getProject(projectRoot);
445
+ const dryRun = request.dryRun === true;
446
+ if (request.connection !== undefined) {
447
+ connectionDef(project, request.connection);
448
+ }
449
+ const targets = request.connection ? [request.connection] : Object.keys(project.connections);
450
+ const registry = await getRegistry(projectRoot, project.config);
451
+ const env = buildEnv(projectRoot);
452
+ const declared = new Set(Object.keys(project.tables));
453
+
454
+ const plan: DataPushStep[] = [];
455
+ const warnings: string[] = [];
456
+ const errors: string[] = [];
457
+ for (const name of targets) {
458
+ const def = project.connections[name]!;
459
+ try {
460
+ const entry = providerEntry(registry, String(def.provider ?? ""));
461
+ const result = (await entry.call(
462
+ "deploySchema",
463
+ tablesFor(project, name),
464
+ {
465
+ ...def,
466
+ $name: name,
467
+ },
468
+ { dryRun, env },
469
+ )) as DeployResult;
470
+ plan.push(...result.statements.map((s) => classifyStatement(s, declared, name)));
471
+ warnings.push(...result.warnings);
472
+ } catch (error) {
473
+ errors.push(`${name}: ${errorMessage(error)}`);
474
+ }
475
+ }
476
+
477
+ // Section-owner push steps: any non-connector project contribution declaring a deploySchema
478
+ // Capability composes its own migration steps after the connector plan (the auth extension's
479
+ // Land as kind "auth" — its section key). The host stays extension-agnostic: registry dispatch
480
+ // Only, no auth import, no "auth" literal, and the same connector stand-ins the mounts see.
481
+ const sectionOwners = registry
482
+ .projectContributions()
483
+ .filter((entry) => entry.connector === null && entry.capabilities.deploySchema);
484
+ if (sectionOwners.length > 0) {
485
+ const connectors = await resolveConnectorStandins(registry);
486
+ for (const entry of sectionOwners) {
487
+ const { key } = entry.project!;
488
+ const section = (project.config as Record<string, unknown>)[key];
489
+ if (section === undefined || section === null) {
490
+ continue;
491
+ }
492
+ try {
493
+ const result = (await entry.call("deploySchema", section, project.config, {
494
+ ...(request.connection === undefined ? {} : { connection: request.connection }),
495
+ connectors,
496
+ dryRun,
497
+ env,
498
+ })) as { steps?: DataPushStep[]; warnings?: string[] };
499
+ for (const step of result.steps ?? []) {
500
+ step.kind ||= key;
501
+ plan.push(step);
502
+ }
503
+ warnings.push(...(result.warnings ?? []));
504
+ } catch (error) {
505
+ errors.push(`${key}: ${errorMessage(error)}`);
506
+ }
507
+ }
508
+ }
509
+
510
+ return {
511
+ applied: !dryRun && errors.length === 0,
512
+ plan,
513
+ ...(warnings.length > 0 ? { warnings } : {}),
514
+ ...(errors.length > 0 ? { errors } : {}),
515
+ };
516
+ }
517
+
518
+ // ─── Rows (admin CRUD — intentionally permission-free, see the module header) ─
519
+
520
+ /**
521
+ * Page a table's rows with column metadata and a total count.
522
+ *
523
+ * @param {string} projectRoot
524
+ * @param {DataRowsQuery} query
525
+ * @returns {Promise<DataRowsResult>}
526
+ */
527
+ export async function queryDataRows(
528
+ projectRoot: string,
529
+ query: DataRowsQuery,
530
+ ): Promise<DataRowsResult> {
531
+ const project = getProject(projectRoot);
532
+ const connection = connectionForTable(project, query.table, query.connection);
533
+ return withDb(projectRoot, project, connection, async (db, kind) => {
534
+ const columns = await tableColumns(db, kind, query.table);
535
+ const names = new Set(columns.map((c) => c.name));
536
+ if (query.orderBy !== undefined && !names.has(query.orderBy)) {
537
+ throw new ApiError(400, `Unknown orderBy column "${query.orderBy}"`);
538
+ }
539
+ const limit = Math.min(Math.max(Math.trunc(query.limit ?? 50), 1), 500);
540
+ const offset = Math.max(Math.trunc(query.offset ?? 0), 0);
541
+
542
+ const { rows: counted } = await sql<{ c: number | bigint }>`
543
+ select count(*) as c from ${sql.table(query.table)}
544
+ `.execute(db);
545
+ const total = Number(counted[0]?.c ?? 0);
546
+
547
+ let qb = db.selectFrom(query.table).selectAll().limit(limit).offset(offset);
548
+ if (query.orderBy !== undefined) {
549
+ qb = qb.orderBy(query.orderBy, query.dir === "desc" ? "desc" : "asc");
550
+ }
551
+ const rows = (await qb.execute()) as Record<string, unknown>[];
552
+ return { columns, rows, total };
553
+ });
554
+ }
555
+
556
+ /**
557
+ * Insert a row. For DECLARED tables the console applies the connector's storage conveniences — a
558
+ * generated uuid id and created_at/updated_at timestamps; undeclared (system) tables get the
559
+ * payload verbatim.
560
+ *
561
+ * @param {string} projectRoot
562
+ * @param {DataRowInsert} request
563
+ * @returns {Promise<{ row: Record<string, unknown> }>}
564
+ */
565
+ export async function insertDataRow(
566
+ projectRoot: string,
567
+ request: DataRowInsert,
568
+ ): Promise<{ row: Record<string, unknown> }> {
569
+ const project = getProject(projectRoot);
570
+ const connection = connectionForTable(project, request.table, request.connection);
571
+ const declared = project.tables[request.table];
572
+ return withDb(projectRoot, project, connection, async (db, kind) => {
573
+ const columns = await tableColumns(db, kind, request.table);
574
+ const row = coerceValues(request.values ?? {}, columns);
575
+ const names = new Set(columns.map((c) => c.name));
576
+ if (declared) {
577
+ if ((declared.id ?? "uuid") === "uuid" && row.id == null) {
578
+ row.id = crypto.randomUUID();
579
+ }
580
+ if (declared.timestamps !== false) {
581
+ const now = new Date().toISOString();
582
+ if (names.has("created_at") && row.created_at == null) {
583
+ row.created_at = now;
584
+ }
585
+ if (names.has("updated_at") && row.updated_at == null) {
586
+ row.updated_at = now;
587
+ }
588
+ }
589
+ }
590
+ const inserted = await db
591
+ .insertInto(request.table)
592
+ .values(row)
593
+ .returningAll()
594
+ .executeTakeFirst();
595
+ return { row: (inserted ?? row) as Record<string, unknown> };
596
+ });
597
+ }
598
+
599
+ /**
600
+ * Update a row keyed on its introspected primary key.
601
+ *
602
+ * @param {string} projectRoot
603
+ * @param {DataRowUpdate} request
604
+ * @returns {Promise<{ row: Record<string, unknown> }>}
605
+ */
606
+ export async function updateDataRow(
607
+ projectRoot: string,
608
+ request: DataRowUpdate,
609
+ ): Promise<{ row: Record<string, unknown> }> {
610
+ const project = getProject(projectRoot);
611
+ const connection = connectionForTable(project, request.table, request.connection);
612
+ const declared = project.tables[request.table];
613
+ return withDb(projectRoot, project, connection, async (db, kind) => {
614
+ const columns = await tableColumns(db, kind, request.table);
615
+ const set = coerceValues(request.set ?? {}, columns);
616
+ if (Object.keys(set).length === 0) {
617
+ throw new ApiError(400, "Empty update");
618
+ }
619
+ const names = new Set(columns.map((c) => c.name));
620
+ if (declared && declared.timestamps !== false && names.has("updated_at")) {
621
+ set.updated_at ??= new Date().toISOString();
622
+ }
623
+ const updated = await db
624
+ .updateTable(request.table)
625
+ .set(set)
626
+ .where(pkColumn(columns), "=", normalizePk(request.pk, columns))
627
+ .returningAll()
628
+ .executeTakeFirst();
629
+ if (!updated) {
630
+ throw new ApiError(404, "Not found");
631
+ }
632
+ return { row: updated as Record<string, unknown> };
633
+ });
634
+ }
635
+
636
+ /**
637
+ * Delete a row keyed on its introspected primary key.
638
+ *
639
+ * @param {string} projectRoot
640
+ * @param {DataRowDelete} request
641
+ * @returns {Promise<{ ok: true }>}
642
+ */
643
+ export async function deleteDataRow(
644
+ projectRoot: string,
645
+ request: DataRowDelete,
646
+ ): Promise<{ ok: true }> {
647
+ const project = getProject(projectRoot);
648
+ const connection = connectionForTable(project, request.table, request.connection);
649
+ return withDb(projectRoot, project, connection, async (db, kind) => {
650
+ const columns = await tableColumns(db, kind, request.table);
651
+ const result = await db
652
+ .deleteFrom(request.table)
653
+ .where(pkColumn(columns), "=", normalizePk(request.pk, columns))
654
+ .executeTakeFirst();
655
+ if ((result.numDeletedRows ?? 0n) === 0n) {
656
+ throw new ApiError(404, "Not found");
657
+ }
658
+ return { ok: true };
659
+ });
660
+ }
661
+
662
+ // ─── Secrets (.dev.vars; names only on the way out) ──────────────────────────
663
+
664
+ const SECRET_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
665
+
666
+ /**
667
+ * The configured secret env-var NAMES — never values.
668
+ *
669
+ * @param {string} projectRoot
670
+ * @returns {Promise<SecretsListResponse>}
671
+ */
672
+ export async function listSecretNames(projectRoot: string): Promise<SecretsListResponse> {
673
+ getProject(projectRoot);
674
+ return { names: Object.keys(loadDevVars(projectRoot)) };
675
+ }
676
+
677
+ /** A line's KEY when it is a `KEY=value` assignment; null for comments/blank/malformed lines. */
678
+ function assignmentKey(rawLine: string): string | null {
679
+ const line = rawLine.trim();
680
+ if (line === "" || line.startsWith("#")) {
681
+ return null;
682
+ }
683
+ const eq = line.indexOf("=");
684
+ if (eq === -1) {
685
+ return null;
686
+ }
687
+ return line.slice(0, eq).trim() || null;
688
+ }
689
+
690
+ /**
691
+ * Write/remove secrets in `<project>/.dev.vars`, preserving comments and line order: existing
692
+ * assignments are replaced in place, removed keys drop their line, and new keys append.
693
+ *
694
+ * @param {string} projectRoot
695
+ * @param {SecretsSetRequest} request
696
+ * @returns {Promise<SecretsSetResponse>}
697
+ */
698
+ export async function setProjectSecrets(
699
+ projectRoot: string,
700
+ request: SecretsSetRequest,
701
+ ): Promise<SecretsSetResponse> {
702
+ getProject(projectRoot);
703
+ const set = request.set ?? {};
704
+ const remove = new Set(request.remove);
705
+ for (const [name, value] of Object.entries(set)) {
706
+ if (!SECRET_NAME.test(name)) {
707
+ throw new ApiError(400, `Invalid secret name "${name}"`);
708
+ }
709
+ if (typeof value !== "string" || value.includes("\n")) {
710
+ throw new ApiError(400, `Secret "${name}" must be a single-line string`);
711
+ }
712
+ }
713
+ for (const name of remove) {
714
+ if (!SECRET_NAME.test(name)) {
715
+ throw new ApiError(400, `Invalid secret name "${name}"`);
716
+ }
717
+ }
718
+
719
+ const path = resolve(projectRoot, ".dev.vars");
720
+ const original = existsSync(path) ? readFileSync(path, "utf8") : "";
721
+ const pending = new Map(Object.entries(set));
722
+ const out: string[] = [];
723
+ for (const line of original.split("\n")) {
724
+ const key = assignmentKey(line);
725
+ if (key !== null && remove.has(key)) {
726
+ continue;
727
+ }
728
+ if (key !== null && pending.has(key)) {
729
+ out.push(`${key}=${pending.get(key)!}`);
730
+ pending.delete(key);
731
+ continue;
732
+ }
733
+ out.push(line);
734
+ }
735
+ while (out.length > 0 && out.at(-1) === "") {
736
+ out.pop();
737
+ }
738
+ for (const [name, value] of pending) {
739
+ out.push(`${name}=${value}`);
740
+ }
741
+ const text = out.length > 0 ? `${out.join("\n")}\n` : "";
742
+ await writeFile(path, text, "utf8");
743
+ return { names: Object.keys(parseDevVars(text)), ok: true };
744
+ }
745
+
746
+ // ─── HTTP surface ─────────────────────────────────────────────────────────────
747
+
748
+ /** Resolve the target project root from the `dir` param with the studio-api containment rule. */
749
+ function resolveProjectRoot(url: URL, root: string, activeProjectRoot: string | null): string {
750
+ const dir = url.searchParams.get("dir") || activeProjectRoot || root;
751
+ const abs = isAbsolute(dir) ? dir : resolve(root, dir);
752
+ const contained = (base: string) => {
753
+ const rel = relative(base, abs);
754
+ return !rel.startsWith("..") && !rel.startsWith("/");
755
+ };
756
+ if (contained(root) || (activeProjectRoot !== null && contained(activeProjectRoot))) {
757
+ return abs;
758
+ }
759
+ throw new ApiError(400, "Path outside project root");
760
+ }
761
+
762
+ /** Parse a JSON request body, 400 on malformed input. */
763
+ async function jsonBody<T>(req: Request): Promise<T> {
764
+ const body = (await req.json().catch(() => null)) as T | null;
765
+ if (body === null || typeof body !== "object") {
766
+ throw new ApiError(400, "Body must be JSON");
767
+ }
768
+ return body;
769
+ }
770
+
771
+ /** A required string param, 400 when missing. */
772
+ function requiredParam(url: URL, name: string): string {
773
+ const value = url.searchParams.get(name);
774
+ if (!value) {
775
+ throw new ApiError(400, `Missing ${name} param`);
776
+ }
777
+ return value;
778
+ }
779
+
780
+ /** An optional integer query param. */
781
+ function intParam(url: URL, name: string): number | undefined {
782
+ const raw = url.searchParams.get(name);
783
+ if (raw === null || raw === "") {
784
+ return undefined;
785
+ }
786
+ const num = Number(raw);
787
+ return Number.isFinite(num) ? num : undefined;
788
+ }
789
+
790
+ /**
791
+ * Handle a `/__studio/data/*` or `/__studio/secrets` request; null when the path is neither.
792
+ *
793
+ * @param {Request} req
794
+ * @param {URL} url
795
+ * @param {string} root - Server root
796
+ * @param {string | null} [activeProjectRoot] - The activated studio project, when any
797
+ * @returns {Promise<Response | null>}
798
+ */
799
+ export async function handleDataApi(
800
+ req: Request,
801
+ url: URL,
802
+ root: string,
803
+ activeProjectRoot: string | null = null,
804
+ ): Promise<Response | null> {
805
+ const path = url.pathname;
806
+ if (!path.startsWith("/__studio/data/") && path !== "/__studio/secrets") {
807
+ return null;
808
+ }
809
+ try {
810
+ const projectRoot = resolveProjectRoot(url, root, activeProjectRoot);
811
+ const route = `${req.method} ${path}`;
812
+ switch (route) {
813
+ case "GET /__studio/data/connections": {
814
+ return Response.json(await listDataConnections(projectRoot));
815
+ }
816
+ case "POST /__studio/data/connections/test": {
817
+ const body = await jsonBody<{ connection?: string }>(req);
818
+ if (!body.connection) {
819
+ throw new ApiError(400, "Missing connection");
820
+ }
821
+ return Response.json(await testDataConnection(projectRoot, body.connection));
822
+ }
823
+ case "POST /__studio/data/push": {
824
+ const body = await jsonBody<DataPushRequest>(req);
825
+ return Response.json(await pushDataSchema(projectRoot, body));
826
+ }
827
+ case "GET /__studio/data/rows": {
828
+ const query: DataRowsQuery = {
829
+ table: requiredParam(url, "table"),
830
+ ...(url.searchParams.get("connection")
831
+ ? { connection: url.searchParams.get("connection")! }
832
+ : {}),
833
+ ...(intParam(url, "limit") === undefined ? {} : { limit: intParam(url, "limit")! }),
834
+ ...(intParam(url, "offset") === undefined ? {} : { offset: intParam(url, "offset")! }),
835
+ ...(url.searchParams.get("orderBy") ? { orderBy: url.searchParams.get("orderBy")! } : {}),
836
+ ...(url.searchParams.get("dir") === "desc" ? { dir: "desc" as const } : {}),
837
+ };
838
+ return Response.json(await queryDataRows(projectRoot, query));
839
+ }
840
+ case "POST /__studio/data/rows": {
841
+ const body = await jsonBody<DataRowInsert>(req);
842
+ return Response.json(await insertDataRow(projectRoot, body), { status: 201 });
843
+ }
844
+ case "PUT /__studio/data/rows": {
845
+ const body = await jsonBody<DataRowUpdate>(req);
846
+ return Response.json(await updateDataRow(projectRoot, body));
847
+ }
848
+ case "DELETE /__studio/data/rows": {
849
+ return Response.json(
850
+ await deleteDataRow(projectRoot, {
851
+ pk: requiredParam(url, "pk"),
852
+ table: requiredParam(url, "table"),
853
+ ...(url.searchParams.get("connection")
854
+ ? { connection: url.searchParams.get("connection")! }
855
+ : {}),
856
+ }),
857
+ );
858
+ }
859
+ case "GET /__studio/secrets": {
860
+ return Response.json(await listSecretNames(projectRoot));
861
+ }
862
+ case "PUT /__studio/secrets": {
863
+ const body = await jsonBody<SecretsSetRequest>(req);
864
+ return Response.json(await setProjectSecrets(projectRoot, body));
865
+ }
866
+ default: {
867
+ throw new ApiError(405, "Method not allowed");
868
+ }
869
+ }
870
+ } catch (error) {
871
+ const status = error instanceof ApiError ? error.status : 500;
872
+ return Response.json({ error: errorMessage(error) }, { status });
873
+ }
874
+ }