@jxsuite/server 4.4.5 → 4.5.1

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/data-api.ts CHANGED
@@ -20,19 +20,26 @@ import { existsSync, readFileSync, statSync } from "node:fs";
20
20
  import { isAbsolute, relative, resolve } from "node:path";
21
21
  import { Kysely, sql } from "kysely";
22
22
  import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
23
+ import { isBoolean, isNonNullObject, isString } from "@jxsuite/schema/guards";
23
24
  import { errorMessage } from "@jxsuite/schema/parse";
24
- import { loadDevVars, parseDevVars } from "./dev-vars.ts";
25
+ import { loadDevVars, parseDevVars, projectDevEnv } from "./dev-vars.ts";
26
+ import type { ProjectDevEnv } from "./dev-vars.ts";
25
27
  import { resolveConnectorStandins } from "./jx-mounts.ts";
26
28
  import { writeFile } from "node:fs/promises";
27
29
  import type { Dialect } from "kysely";
28
30
  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
+ ConnectorBlock,
33
+ FormatEntry,
34
+ SectionDeployOptions,
35
+ } from "@jxsuite/schema/format-registry";
36
+ import type { JsonObject, ProjectConfig } from "@jxsuite/schema/types";
31
37
  import type {
32
38
  DataColumnMeta,
33
39
  DataConnectionInfo,
34
40
  DataConnectionsResponse,
35
41
  DataConnectionTestResult,
42
+ DataConnectorInfo,
36
43
  DataPushRequest,
37
44
  DataPushResult,
38
45
  DataPushStep,
@@ -42,35 +49,69 @@ import type {
42
49
  DataRowsResult,
43
50
  DataRowUpdate,
44
51
  SecretsListResponse,
45
- SecretsSetRequest,
52
+ KeyValuePatch,
46
53
  SecretsSetResponse,
47
54
  } from "@jxsuite/protocol";
48
55
  import { problem, problemTypeForStatus } from "./problem.ts";
49
56
 
50
57
  // ─── Local section shapes (structural — the connector owns the full types) ───
51
58
 
52
- /** One `connections` entry: identifiers and env-var names only, never secrets. */
53
- interface ConnectionDef {
54
- provider?: string;
55
- [key: string]: unknown;
56
- }
57
-
58
59
  /** One `data` entry (the slice this console needs). */
59
60
  interface TableDef {
60
61
  connection?: string;
61
62
  id?: string;
62
63
  timestamps?: boolean;
63
- schema?: { properties?: Record<string, unknown> };
64
+ schema?: { properties?: JsonObject };
64
65
  }
65
66
 
66
67
  interface ProjectSections {
67
68
  config: ProjectConfig;
68
- connections: Record<string, ConnectionDef>;
69
+ /**
70
+ * The `connections` section by name. Each entry is JSON holding identifiers and env-var names
71
+ * only, never secrets; `provider` names the connector serving it.
72
+ */
73
+ connections: Record<string, JsonObject>;
69
74
  tables: Record<string, TableDef>;
70
75
  }
71
76
 
72
- /** Loosely-typed Kysely database — dynamic tables are only known at runtime. */
73
- type Db = Kysely<Record<string, Record<string, unknown>>>;
77
+ /**
78
+ * Loosely-typed Kysely database — dynamic tables are only known at runtime, and a row is the JSON
79
+ * object the console's wire carries (`DataRowsResult.rows` in @jxsuite/protocol).
80
+ */
81
+ type Db = Kysely<Record<string, JsonObject>>;
82
+
83
+ /**
84
+ * A row insert as it arrives over HTTP or RPC: nothing upstream has confirmed `values` is present,
85
+ * so a missing payload inserts an empty row rather than crashing.
86
+ */
87
+ interface ArrivingRowInsert extends Omit<DataRowInsert, "values"> {
88
+ values?: JsonObject;
89
+ }
90
+
91
+ /** A row update as it arrives over HTTP or RPC; a missing `set` is refused as an empty update. */
92
+ interface ArrivingRowUpdate extends Omit<DataRowUpdate, "set"> {
93
+ set?: JsonObject;
94
+ }
95
+
96
+ /**
97
+ * How this console reads a section owner's `deploySchema` answer (`SectionDeployResult`): its steps
98
+ * and warnings, each optional and defaulted where it is read.
99
+ */
100
+ interface SectionDeployReading {
101
+ steps?: DataPushStep[];
102
+ warnings?: string[];
103
+ }
104
+
105
+ declare module "@jxsuite/schema/format-registry" {
106
+ /**
107
+ * A connector's `dialect` (specs/extensions.md §8) answers the Kysely dialect a connection opens
108
+ * with. The schema package cannot name Kysely's type, so this host, which opens the connections,
109
+ * states the role's contract.
110
+ */
111
+ interface CapabilityContracts {
112
+ dialect: { args: [connection: JsonObject, env: HostEnvironment]; result: Dialect };
113
+ }
114
+ }
74
115
 
75
116
  /** A route-mappable failure (`status` becomes the HTTP status). */
76
117
  class ApiError extends Error {
@@ -82,13 +123,6 @@ class ApiError extends Error {
82
123
  }
83
124
  }
84
125
 
85
- /** The shape of the connector `deploySchema` capability result. */
86
- interface DeployResult {
87
- statements: string[];
88
- applied: boolean;
89
- warnings: string[];
90
- }
91
-
92
126
  // ─── Project + registry resolution ───────────────────────────────────────────
93
127
 
94
128
  /** Per-project registry cache, invalidated when project.json's mtime moves. */
@@ -104,15 +138,21 @@ function getProject(projectRoot: string): ProjectSections {
104
138
  const path = resolve(projectRoot, "project.json");
105
139
  let config: ProjectConfig;
106
140
  try {
141
+ /* SAFETY: trust boundary. project.json is the project's own config, held to its per-project
142
+ schema by the editor and `jx validate`; the connections and data sections are narrowed
143
+ below, and unreadable or non-JSON text answers the 404 in the catch. */
107
144
  config = JSON.parse(readFileSync(path, "utf8")) as ProjectConfig;
108
145
  } catch (error) {
109
146
  throw new ApiError(404, `No project.json under "${projectRoot}"`, { cause: error });
110
147
  }
111
- // Section keys are the connector's host wire contract (the same literals jx-mounts passes to
112
- // The Data mount's options.sections) — JSON introspection, not a connector import.
113
- const cfg = config as Record<string, unknown>;
114
- const connections = (cfg.connections ?? {}) as Record<string, ConnectionDef>;
115
- const tables = (cfg.data ?? {}) as Record<string, TableDef>;
148
+ /* Section keys are the connector's host wire contract (the same literals jx-mounts passes to
149
+ the Data mount's options.sections) — JSON introspection, not a connector import.
150
+ SAFETY: `connections` and `data` are the connector extension's sections, which its
151
+ Connections.class.json and Data.class.json descriptors declare as maps keyed by connection or
152
+ table name; every member this console reads from an entry is re-checked where it is read. */
153
+ const connections = (config.connections ?? {}) as Record<string, JsonObject>;
154
+ // SAFETY: as above, for the `data` section's table definitions.
155
+ const tables = (config.data ?? {}) as Record<string, TableDef>;
116
156
  return { config, connections, tables };
117
157
  }
118
158
 
@@ -129,11 +169,6 @@ function getRegistry(projectRoot: string, config: ProjectConfig): Promise<Extens
129
169
  return registry;
130
170
  }
131
171
 
132
- /** The mount environment: process.env merged under `.dev.vars` plus JX_PROJECT_ROOT. */
133
- function buildEnv(projectRoot: string): Record<string, unknown> {
134
- return { ...process.env, ...loadDevVars(projectRoot), JX_PROJECT_ROOT: projectRoot };
135
- }
136
-
137
172
  /**
138
173
  * The registry entry serving a provider, after the dev `local: "<provider>"` stand-in rule
139
174
  * (specs/extensions.md §12) — identical to jx-mounts' resolution.
@@ -145,13 +180,11 @@ function providerEntry(registry: ExtensionRegistry, provider: string): FormatEnt
145
180
  throw new ApiError(400, `No connector provider "${provider}" is available`);
146
181
  }
147
182
  const { local } = entry.connector!;
148
- return typeof local === "string"
149
- ? (entries.find((e) => e.connector!.provider === local) ?? entry)
150
- : entry;
183
+ return isString(local) ? (entries.find((e) => e.connector!.provider === local) ?? entry) : entry;
151
184
  }
152
185
 
153
186
  /** The connection def by name, 404 when undeclared. */
154
- function connectionDef(project: ProjectSections, name: string): ConnectionDef {
187
+ function connectionDef(project: ProjectSections, name: string): JsonObject {
155
188
  const def = project.connections[name];
156
189
  if (!def) {
157
190
  throw new ApiError(404, `Unknown connection "${name}"`);
@@ -161,13 +194,9 @@ function connectionDef(project: ProjectSections, name: string): ConnectionDef {
161
194
 
162
195
  /** Tables of one connection (the data section filtered by connection name). */
163
196
  function tablesFor(project: ProjectSections, connection: string): Record<string, TableDef> {
164
- const out: Record<string, TableDef> = {};
165
- for (const [name, def] of Object.entries(project.tables)) {
166
- if (def.connection === connection) {
167
- out[name] = def;
168
- }
169
- }
170
- return out;
197
+ return Object.fromEntries(
198
+ Object.entries(project.tables).filter(([, def]) => def.connection === connection),
199
+ );
171
200
  }
172
201
 
173
202
  /**
@@ -194,11 +223,15 @@ async function withDb<T>(
194
223
  const def = connectionDef(project, connectionName);
195
224
  const registry = await getRegistry(projectRoot, project.config);
196
225
  const entry = providerEntry(registry, String(def.provider ?? ""));
197
- const env = buildEnv(projectRoot);
198
- const dialect = (await entry.call("dialect", { ...def, $name: connectionName }, env)) as Dialect;
199
- const db = new Kysely<Record<string, Record<string, unknown>>>({ dialect });
226
+ const env = projectDevEnv(projectRoot);
227
+ // A provider that breaks the `dialect` contract fails at the first query.
228
+ const dialect = await entry.call("dialect", { ...def, $name: connectionName }, env);
229
+ const db: Db = new Kysely({ dialect });
230
+ /* The descriptor is extension JSON the registry does not validate, so `kind` may be absent
231
+ whatever ConnectorBlock declares. */
232
+ const declared: Partial<ConnectorBlock> = entry.connector!;
200
233
  try {
201
- return await fn(db, String(entry.connector!.kind ?? "sqlite"));
234
+ return await fn(db, String(declared.kind ?? "sqlite"));
202
235
  } finally {
203
236
  await db.destroy();
204
237
  }
@@ -248,28 +281,20 @@ async function primaryKeys(db: Db, kind: string, table: string): Promise<Set<str
248
281
  }
249
282
 
250
283
  /** Light storage coercion for admin writes, keyed on the introspected column types. */
251
- function coerceValues(
252
- values: Record<string, unknown>,
253
- columns: DataColumnMeta[],
254
- ): Record<string, unknown> {
284
+ function coerceValues(values: JsonObject, columns: DataColumnMeta[]): JsonObject {
255
285
  const byName = new Map(columns.map((c) => [c.name, c]));
256
- const out: Record<string, unknown> = {};
286
+ const out: JsonObject = {};
257
287
  for (const [key, raw] of Object.entries(values)) {
258
288
  const column = byName.get(key);
259
289
  if (!column) {
260
290
  throw new ApiError(400, `Unknown column "${key}"`);
261
291
  }
262
292
  const numeric = /int|real|double|numeric|decimal/i.test(column.type);
263
- if (typeof raw === "boolean" && numeric) {
293
+ if (isBoolean(raw) && numeric) {
264
294
  out[key] = raw ? 1 : 0;
265
- } else if (
266
- typeof raw === "string" &&
267
- numeric &&
268
- raw.trim() !== "" &&
269
- !Number.isNaN(Number(raw))
270
- ) {
295
+ } else if (isString(raw) && numeric && raw.trim() !== "" && !Number.isNaN(Number(raw))) {
271
296
  out[key] = Number(raw);
272
- } else if (raw !== null && typeof raw === "object") {
297
+ } else if (isNonNullObject(raw)) {
273
298
  out[key] = JSON.stringify(raw);
274
299
  } else {
275
300
  out[key] = raw;
@@ -281,7 +306,7 @@ function coerceValues(
281
306
  /** Normalize a wire pk value against the pk column's type (integer pks arrive as strings). */
282
307
  function normalizePk(pk: string | number, columns: DataColumnMeta[]): string | number {
283
308
  const pkMeta = columns.find((c) => c.pk);
284
- if (typeof pk === "string" && pkMeta && /int/i.test(pkMeta.type) && /^\d+$/.test(pk)) {
309
+ if (isString(pk) && pkMeta && /int/i.test(pkMeta.type) && /^\d+$/.test(pk)) {
285
310
  return Number(pk);
286
311
  }
287
312
  return pk;
@@ -295,10 +320,10 @@ function pkColumn(columns: DataColumnMeta[]): string {
295
320
  // ─── Connections ──────────────────────────────────────────────────────────────
296
321
 
297
322
  /** Env-var NAMES a connection references (keys ending in "Env") that resolve to no value. */
298
- function missingSecretNames(def: ConnectionDef, env: Record<string, unknown>): string[] {
323
+ function missingSecretNames(def: JsonObject, env: ProjectDevEnv): string[] {
299
324
  const missing: string[] = [];
300
325
  for (const [key, value] of Object.entries(def)) {
301
- if (key.endsWith("Env") && typeof value === "string" && value !== "" && env[value] == null) {
326
+ if (key.endsWith("Env") && isString(value) && value !== "" && env[value] == null) {
302
327
  missing.push(value);
303
328
  }
304
329
  }
@@ -317,7 +342,7 @@ export async function listDataConnections(projectRoot: string): Promise<DataConn
317
342
  const project = getProject(projectRoot);
318
343
  const registry = await getRegistry(projectRoot, project.config);
319
344
  const entries = registry.connectors();
320
- const env = buildEnv(projectRoot);
345
+ const env = projectDevEnv(projectRoot);
321
346
  const names = Object.keys(project.connections);
322
347
 
323
348
  const connections: DataConnectionInfo[] = [];
@@ -342,16 +367,7 @@ export async function listDataConnections(projectRoot: string): Promise<DataConn
342
367
 
343
368
  connections.push({
344
369
  configured: missingSecrets.length === 0,
345
- connector: entry
346
- ? {
347
- provider,
348
- ...(entry.connector!.kind == null ? {} : { kind: String(entry.connector!.kind) }),
349
- ...(entry.connector!.local == null ? {} : { local: String(entry.connector!.local) }),
350
- ...(typeof entry.classDef.description === "string"
351
- ? { description: entry.classDef.description }
352
- : {}),
353
- }
354
- : null,
370
+ connector: entry ? connectorInfo(entry, provider) : null,
355
371
  isDefault: index === 0,
356
372
  missingSecrets,
357
373
  name,
@@ -363,6 +379,24 @@ export async function listDataConnections(projectRoot: string): Promise<DataConn
363
379
  return { connections };
364
380
  }
365
381
 
382
+ /** The provider metadata a connection row shows, read off the serving entry's descriptor. */
383
+ function connectorInfo(entry: FormatEntry, provider: string): DataConnectorInfo {
384
+ /* The descriptor is extension JSON the registry does not validate, so `kind` may be absent
385
+ whatever ConnectorBlock declares. */
386
+ const block: Partial<ConnectorBlock> = entry.connector!;
387
+ const info: DataConnectorInfo = { provider };
388
+ if (block.kind != null) {
389
+ info.kind = String(block.kind);
390
+ }
391
+ if (block.local != null) {
392
+ info.local = String(block.local);
393
+ }
394
+ if (isString(entry.classDef.description)) {
395
+ info.description = entry.classDef.description;
396
+ }
397
+ return info;
398
+ }
399
+
366
400
  /**
367
401
  * Probe a connection through the registry's testConnection capability (stand-in applied).
368
402
  *
@@ -378,13 +412,14 @@ export async function testDataConnection(
378
412
  const def = connectionDef(project, connection);
379
413
  const registry = await getRegistry(projectRoot, project.config);
380
414
  const entry = providerEntry(registry, String(def.provider ?? ""));
381
- const env = buildEnv(projectRoot);
382
- const result = (await entry.call(
383
- "testConnection",
384
- { ...def, $name: connection },
385
- env,
386
- )) as DataConnectionTestResult;
387
- return { ok: result.ok === true, ...(result.error === undefined ? {} : { error: result.error }) };
415
+ const env = projectDevEnv(projectRoot);
416
+ // `ok` is compared with `=== true`, so only an explicit success counts.
417
+ const result = await entry.call("testConnection", { ...def, $name: connection }, env);
418
+ const outcome: DataConnectionTestResult = { ok: result.ok === true };
419
+ if (result.error !== undefined) {
420
+ outcome.error = result.error;
421
+ }
422
+ return outcome;
388
423
  }
389
424
 
390
425
  // ─── Push ─────────────────────────────────────────────────────────────────────
@@ -449,7 +484,7 @@ export async function pushDataSchema(
449
484
  }
450
485
  const targets = request.connection ? [request.connection] : Object.keys(project.connections);
451
486
  const registry = await getRegistry(projectRoot, project.config);
452
- const env = buildEnv(projectRoot);
487
+ const env = projectDevEnv(projectRoot);
453
488
  const declared = new Set(Object.keys(project.tables));
454
489
 
455
490
  const plan: DataPushStep[] = [];
@@ -459,15 +494,13 @@ export async function pushDataSchema(
459
494
  const def = project.connections[name]!;
460
495
  try {
461
496
  const entry = providerEntry(registry, String(def.provider ?? ""));
462
- const result = (await entry.call(
497
+ // A provider that throws, or answers without its statements, is reported below.
498
+ const result = await entry.call(
463
499
  "deploySchema",
464
500
  tablesFor(project, name),
465
- {
466
- ...def,
467
- $name: name,
468
- },
501
+ { ...def, $name: name },
469
502
  { dryRun, env },
470
- )) as DeployResult;
503
+ );
471
504
  plan.push(...result.statements.map((s) => classifyStatement(s, declared, name)));
472
505
  warnings.push(...result.warnings);
473
506
  } catch (error) {
@@ -486,17 +519,22 @@ export async function pushDataSchema(
486
519
  const connectors = await resolveConnectorStandins(registry);
487
520
  for (const entry of sectionOwners) {
488
521
  const { key } = entry.project!;
489
- const section = (project.config as Record<string, unknown>)[key];
522
+ const section = project.config[key];
490
523
  if (section === undefined || section === null) {
491
524
  continue;
492
525
  }
526
+ const options: SectionDeployOptions =
527
+ request.connection === undefined
528
+ ? { connectors, dryRun, env }
529
+ : { connection: request.connection, connectors, dryRun, env };
493
530
  try {
494
- const result = (await entry.call("deploySchema", section, project.config, {
495
- ...(request.connection === undefined ? {} : { connection: request.connection }),
496
- connectors,
497
- dryRun,
498
- env,
499
- })) as { steps?: DataPushStep[]; warnings?: string[] };
531
+ // A capability that throws is reported below.
532
+ const result: SectionDeployReading = await entry.call(
533
+ "deploySchema",
534
+ section,
535
+ project.config,
536
+ options,
537
+ );
500
538
  for (const step of result.steps ?? []) {
501
539
  step.kind ||= key;
502
540
  plan.push(step);
@@ -508,12 +546,14 @@ export async function pushDataSchema(
508
546
  }
509
547
  }
510
548
 
511
- return {
512
- applied: !dryRun && errors.length === 0,
513
- plan,
514
- ...(warnings.length > 0 ? { warnings } : {}),
515
- ...(errors.length > 0 ? { errors } : {}),
516
- };
549
+ const pushed: DataPushResult = { applied: !dryRun && errors.length === 0, plan };
550
+ if (warnings.length > 0) {
551
+ pushed.warnings = warnings;
552
+ }
553
+ if (errors.length > 0) {
554
+ pushed.errors = errors;
555
+ }
556
+ return pushed;
517
557
  }
518
558
 
519
559
  // ─── Rows (admin CRUD — intentionally permission-free, see the module header) ─
@@ -549,7 +589,7 @@ export async function queryDataRows(
549
589
  if (query.orderBy !== undefined) {
550
590
  qb = qb.orderBy(query.orderBy, query.dir === "desc" ? "desc" : "asc");
551
591
  }
552
- const rows = (await qb.execute()) as Record<string, unknown>[];
592
+ const rows = await qb.execute();
553
593
  return { columns, rows, total };
554
594
  });
555
595
  }
@@ -561,12 +601,12 @@ export async function queryDataRows(
561
601
  *
562
602
  * @param {string} projectRoot
563
603
  * @param {DataRowInsert} request
564
- * @returns {Promise<{ row: Record<string, unknown> }>}
604
+ * @returns {Promise<{ row: JsonObject }>}
565
605
  */
566
606
  export async function insertDataRow(
567
607
  projectRoot: string,
568
- request: DataRowInsert,
569
- ): Promise<{ row: Record<string, unknown> }> {
608
+ request: ArrivingRowInsert,
609
+ ): Promise<{ row: JsonObject }> {
570
610
  const project = getProject(projectRoot);
571
611
  const connection = connectionForTable(project, request.table, request.connection);
572
612
  const declared = project.tables[request.table];
@@ -593,7 +633,7 @@ export async function insertDataRow(
593
633
  .values(row)
594
634
  .returningAll()
595
635
  .executeTakeFirst();
596
- return { row: (inserted ?? row) as Record<string, unknown> };
636
+ return { row: inserted ?? row };
597
637
  });
598
638
  }
599
639
 
@@ -602,12 +642,12 @@ export async function insertDataRow(
602
642
  *
603
643
  * @param {string} projectRoot
604
644
  * @param {DataRowUpdate} request
605
- * @returns {Promise<{ row: Record<string, unknown> }>}
645
+ * @returns {Promise<{ row: JsonObject }>}
606
646
  */
607
647
  export async function updateDataRow(
608
648
  projectRoot: string,
609
- request: DataRowUpdate,
610
- ): Promise<{ row: Record<string, unknown> }> {
649
+ request: ArrivingRowUpdate,
650
+ ): Promise<{ row: JsonObject }> {
611
651
  const project = getProject(projectRoot);
612
652
  const connection = connectionForTable(project, request.table, request.connection);
613
653
  const declared = project.tables[request.table];
@@ -630,7 +670,7 @@ export async function updateDataRow(
630
670
  if (!updated) {
631
671
  throw new ApiError(404, "Not found");
632
672
  }
633
- return { row: updated as Record<string, unknown> };
673
+ return { row: updated };
634
674
  });
635
675
  }
636
676
 
@@ -653,7 +693,7 @@ export async function deleteDataRow(
653
693
  .deleteFrom(request.table)
654
694
  .where(pkColumn(columns), "=", normalizePk(request.pk, columns))
655
695
  .executeTakeFirst();
656
- if ((result.numDeletedRows ?? 0n) === 0n) {
696
+ if (result.numDeletedRows === 0n) {
657
697
  throw new ApiError(404, "Not found");
658
698
  }
659
699
  return { ok: true };
@@ -693,12 +733,12 @@ function assignmentKey(rawLine: string): string | null {
693
733
  * assignments are replaced in place, removed keys drop their line, and new keys append.
694
734
  *
695
735
  * @param {string} projectRoot
696
- * @param {SecretsSetRequest} request
736
+ * @param {KeyValuePatch} request
697
737
  * @returns {Promise<SecretsSetResponse>}
698
738
  */
699
739
  export async function setProjectSecrets(
700
740
  projectRoot: string,
701
- request: SecretsSetRequest,
741
+ request: KeyValuePatch,
702
742
  ): Promise<SecretsSetResponse> {
703
743
  getProject(projectRoot);
704
744
  const set = request.set ?? {};
@@ -707,7 +747,7 @@ export async function setProjectSecrets(
707
747
  if (!SECRET_NAME.test(name)) {
708
748
  throw new ApiError(400, `Invalid secret name "${name}"`);
709
749
  }
710
- if (typeof value !== "string" || value.includes("\n")) {
750
+ if (!isString(value) || value.includes("\n")) {
711
751
  throw new ApiError(400, `Secret "${name}" must be a single-line string`);
712
752
  }
713
753
  }
@@ -761,12 +801,15 @@ function resolveProjectRoot(url: URL, root: string, activeProjectRoot: string |
761
801
  }
762
802
 
763
803
  /** Parse a JSON request body, 400 on malformed input. */
764
- async function jsonBody<T>(req: Request): Promise<T> {
765
- const body = (await req.json().catch(() => null)) as T | null;
766
- if (body === null || typeof body !== "object") {
804
+ async function jsonBody<T extends object>(req: Request): Promise<T> {
805
+ const body: unknown = await req.json().catch(() => null);
806
+ if (!isNonNullObject(body)) {
767
807
  throw new ApiError(400, "Body must be JSON");
768
808
  }
769
- return body;
809
+ /* SAFETY: each caller names its route's protocol body, which is what the Studio client posts;
810
+ only object-ness is checked here, and every member is re-checked where it is read (missing
811
+ payloads default, pks and secret values are type-tested, names go through connectionDef). */
812
+ return body as T;
770
813
  }
771
814
 
772
815
  /** A required string param, 400 when missing. */
@@ -826,16 +869,26 @@ export async function handleDataApi(
826
869
  return Response.json(await pushDataSchema(projectRoot, body));
827
870
  }
828
871
  case "GET /__studio/data/rows": {
829
- const query: DataRowsQuery = {
830
- table: requiredParam(url, "table"),
831
- ...(url.searchParams.get("connection")
832
- ? { connection: url.searchParams.get("connection")! }
833
- : {}),
834
- ...(intParam(url, "limit") === undefined ? {} : { limit: intParam(url, "limit")! }),
835
- ...(intParam(url, "offset") === undefined ? {} : { offset: intParam(url, "offset")! }),
836
- ...(url.searchParams.get("orderBy") ? { orderBy: url.searchParams.get("orderBy")! } : {}),
837
- ...(url.searchParams.get("dir") === "desc" ? { dir: "desc" as const } : {}),
838
- };
872
+ const query: DataRowsQuery = { table: requiredParam(url, "table") };
873
+ const connection = url.searchParams.get("connection");
874
+ if (connection) {
875
+ query.connection = connection;
876
+ }
877
+ const limit = intParam(url, "limit");
878
+ if (limit !== undefined) {
879
+ query.limit = limit;
880
+ }
881
+ const offset = intParam(url, "offset");
882
+ if (offset !== undefined) {
883
+ query.offset = offset;
884
+ }
885
+ const orderBy = url.searchParams.get("orderBy");
886
+ if (orderBy) {
887
+ query.orderBy = orderBy;
888
+ }
889
+ if (url.searchParams.get("dir") === "desc") {
890
+ query.dir = "desc";
891
+ }
839
892
  return Response.json(await queryDataRows(projectRoot, query));
840
893
  }
841
894
  case "POST /__studio/data/rows": {
@@ -847,21 +900,21 @@ export async function handleDataApi(
847
900
  return Response.json(await updateDataRow(projectRoot, body));
848
901
  }
849
902
  case "DELETE /__studio/data/rows": {
850
- return Response.json(
851
- await deleteDataRow(projectRoot, {
852
- pk: requiredParam(url, "pk"),
853
- table: requiredParam(url, "table"),
854
- ...(url.searchParams.get("connection")
855
- ? { connection: url.searchParams.get("connection")! }
856
- : {}),
857
- }),
858
- );
903
+ const target: DataRowDelete = {
904
+ pk: requiredParam(url, "pk"),
905
+ table: requiredParam(url, "table"),
906
+ };
907
+ const connection = url.searchParams.get("connection");
908
+ if (connection) {
909
+ target.connection = connection;
910
+ }
911
+ return Response.json(await deleteDataRow(projectRoot, target));
859
912
  }
860
913
  case "GET /__studio/secrets": {
861
914
  return Response.json(await listSecretNames(projectRoot));
862
915
  }
863
916
  case "PUT /__studio/secrets": {
864
- const body = await jsonBody<SecretsSetRequest>(req);
917
+ const body = await jsonBody<KeyValuePatch>(req);
865
918
  return Response.json(await setProjectSecrets(projectRoot, body));
866
919
  }
867
920
  default: {
package/src/dev-vars.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  * Dev-vars — `<project>/.dev.vars` parsing (wrangler convention, specs/extensions.md §13).
3
3
  *
4
4
  * Secret VALUES never enter project.json; locally they live in the git-ignored .dev.vars file and
5
- * the dev server merges them over process.env when constructing mount environments. The format is
5
+ * the dev server merges them over process.env when constructing the `env` it hands project code:
6
+ * the extension mounts' and the server-function proxy's (`projectDevEnv`). The format is
6
7
  * dotenv-shaped: KEY=VALUE lines, `#` comments, optional single/double quotes. The compiler CLI
7
8
  * keeps its own tiny copy (db-push.ts) — the compiler cannot depend on this package.
8
9
  */
@@ -10,6 +11,15 @@
10
11
  import { existsSync, readFileSync } from "node:fs";
11
12
  import { resolve } from "node:path";
12
13
 
14
+ /**
15
+ * The `env` project code receives on a development host: the process environment with the project's
16
+ * `.dev.vars` merged over it, and `JX_PROJECT_ROOT` always present.
17
+ */
18
+ export interface ProjectDevEnv {
19
+ [name: string]: string | undefined;
20
+ JX_PROJECT_ROOT: string;
21
+ }
22
+
13
23
  /**
14
24
  * Parse .dev.vars text into a name → value record.
15
25
  *
@@ -17,7 +27,8 @@ import { resolve } from "node:path";
17
27
  * @returns {Record<string, string>}
18
28
  */
19
29
  export function parseDevVars(text: string): Record<string, string> {
20
- const out: Record<string, string> = {};
30
+ // A later line for the same name replaces the earlier value, as dotenv loaders do.
31
+ const vars = new Map<string, string>();
21
32
  for (const rawLine of text.split("\n")) {
22
33
  const line = rawLine.trim();
23
34
  if (line === "" || line.startsWith("#")) {
@@ -36,10 +47,10 @@ export function parseDevVars(text: string): Record<string, string> {
36
47
  value = value.slice(1, -1);
37
48
  }
38
49
  if (key !== "") {
39
- out[key] = value;
50
+ vars.set(key, value);
40
51
  }
41
52
  }
42
- return out;
53
+ return Object.fromEntries(vars);
43
54
  }
44
55
 
45
56
  /**
@@ -59,3 +70,16 @@ export function loadDevVars(projectRoot: string): Record<string, string> {
59
70
  return {};
60
71
  }
61
72
  }
73
+
74
+ /**
75
+ * The `env` a development host hands project code that runs server-side: `process.env` merged under
76
+ * the project's `.dev.vars`, plus `JX_PROJECT_ROOT`. The extension mounts (server.md §3) and the
77
+ * server-function proxy (server.md §3.3) both build theirs here, so a server function reads the
78
+ * same secrets a mount does.
79
+ *
80
+ * @param {string} root - Absolute project root
81
+ * @returns {ProjectDevEnv}
82
+ */
83
+ export function projectDevEnv(root: string): ProjectDevEnv {
84
+ return { ...process.env, ...loadDevVars(root), JX_PROJECT_ROOT: root };
85
+ }