@jxsuite/server 4.5.0 → 6.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.
- package/package.json +18 -18
- package/src/ai-api.ts +8 -4
- package/src/build.ts +6 -6
- package/src/code-api.ts +29 -16
- package/src/collab.ts +9 -5
- package/src/data-api.ts +190 -137
- package/src/dev-vars.ts +15 -5
- package/src/dev.ts +3 -0
- package/src/extension-catalog.ts +62 -32
- package/src/import-api.ts +96 -54
- package/src/jx-mounts.ts +27 -27
- package/src/live-preview.ts +16 -10
- package/src/oauth-loopback.ts +23 -18
- package/src/packages.ts +28 -38
- package/src/problem.ts +3 -6
- package/src/project-server.ts +39 -9
- package/src/refactor/apply.ts +3 -2
- package/src/refactor/find-refs.ts +0 -0
- package/src/refactor/fs-events.ts +14 -15
- package/src/refactor/paths.ts +19 -10
- package/src/refactor/refs.ts +60 -63
- package/src/refactor/scan.ts +13 -19
- package/src/refactor/watcher.ts +5 -5
- package/src/resolve.ts +269 -82
- package/src/server.ts +52 -28
- package/src/site-output.ts +11 -5
- package/src/sse.ts +16 -2
- package/src/studio-api.ts +389 -145
- package/src/types.ts +17 -7
- package/src/watch.ts +8 -6
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 {
|
|
30
|
-
|
|
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
|
-
|
|
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?:
|
|
64
|
+
schema?: { properties?: JsonObject };
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
interface ProjectSections {
|
|
67
68
|
config: ProjectConfig;
|
|
68
|
-
|
|
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
|
-
/**
|
|
73
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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):
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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 =
|
|
198
|
-
|
|
199
|
-
const
|
|
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(
|
|
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:
|
|
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 (
|
|
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
|
|
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 (
|
|
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:
|
|
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") &&
|
|
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 =
|
|
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 =
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
)
|
|
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 =
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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 =
|
|
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:
|
|
604
|
+
* @returns {Promise<{ row: JsonObject }>}
|
|
565
605
|
*/
|
|
566
606
|
export async function insertDataRow(
|
|
567
607
|
projectRoot: string,
|
|
568
|
-
request:
|
|
569
|
-
): Promise<{ row:
|
|
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:
|
|
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:
|
|
645
|
+
* @returns {Promise<{ row: JsonObject }>}
|
|
606
646
|
*/
|
|
607
647
|
export async function updateDataRow(
|
|
608
648
|
projectRoot: string,
|
|
609
|
-
request:
|
|
610
|
-
): Promise<{ row:
|
|
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
|
|
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 (
|
|
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 {
|
|
736
|
+
* @param {KeyValuePatch} request
|
|
697
737
|
* @returns {Promise<SecretsSetResponse>}
|
|
698
738
|
*/
|
|
699
739
|
export async function setProjectSecrets(
|
|
700
740
|
projectRoot: string,
|
|
701
|
-
request:
|
|
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 (
|
|
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 =
|
|
766
|
-
if (body
|
|
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
|
-
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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<
|
|
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
|
@@ -11,6 +11,15 @@
|
|
|
11
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
12
12
|
import { resolve } from "node:path";
|
|
13
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
|
+
|
|
14
23
|
/**
|
|
15
24
|
* Parse .dev.vars text into a name → value record.
|
|
16
25
|
*
|
|
@@ -18,7 +27,8 @@ import { resolve } from "node:path";
|
|
|
18
27
|
* @returns {Record<string, string>}
|
|
19
28
|
*/
|
|
20
29
|
export function parseDevVars(text: string): Record<string, string> {
|
|
21
|
-
|
|
30
|
+
// A later line for the same name replaces the earlier value, as dotenv loaders do.
|
|
31
|
+
const vars = new Map<string, string>();
|
|
22
32
|
for (const rawLine of text.split("\n")) {
|
|
23
33
|
const line = rawLine.trim();
|
|
24
34
|
if (line === "" || line.startsWith("#")) {
|
|
@@ -37,10 +47,10 @@ export function parseDevVars(text: string): Record<string, string> {
|
|
|
37
47
|
value = value.slice(1, -1);
|
|
38
48
|
}
|
|
39
49
|
if (key !== "") {
|
|
40
|
-
|
|
50
|
+
vars.set(key, value);
|
|
41
51
|
}
|
|
42
52
|
}
|
|
43
|
-
return
|
|
53
|
+
return Object.fromEntries(vars);
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
/**
|
|
@@ -68,8 +78,8 @@ export function loadDevVars(projectRoot: string): Record<string, string> {
|
|
|
68
78
|
* same secrets a mount does.
|
|
69
79
|
*
|
|
70
80
|
* @param {string} root - Absolute project root
|
|
71
|
-
* @returns {
|
|
81
|
+
* @returns {ProjectDevEnv}
|
|
72
82
|
*/
|
|
73
|
-
export function projectDevEnv(root: string):
|
|
83
|
+
export function projectDevEnv(root: string): ProjectDevEnv {
|
|
74
84
|
return { ...process.env, ...loadDevVars(root), JX_PROJECT_ROOT: root };
|
|
75
85
|
}
|
package/src/dev.ts
CHANGED
|
@@ -124,6 +124,9 @@ export async function startDev({ port, root }: DevArgs): Promise<DevServerHandle
|
|
|
124
124
|
await rebuild();
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/* SAFETY: createDevServer always calls Bun.serve with a hostname and TCP port, never a unix
|
|
128
|
+
socket, so the returned Server's `port` (typed `number | undefined` for the socket case) is a
|
|
129
|
+
number; its promise-returning `stop()` satisfies `() => void`. */
|
|
127
130
|
return (await createDevServer({
|
|
128
131
|
port,
|
|
129
132
|
root,
|