@jxsuite/compiler 0.35.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +191 -164
- package/dist/compiler.js +121 -114
- package/package.json +11 -7
- package/src/cli.ts +132 -9
- package/src/shared.ts +53 -4
- package/src/site/context-injection.ts +0 -6
- package/src/site/db-push.ts +178 -0
- package/src/site/dev-command.ts +84 -0
- package/src/site/format-host.ts +128 -13
- package/src/site/head-merger.ts +13 -0
- package/src/site/image-cache.ts +57 -6
- package/src/site/image-transform.ts +21 -0
- package/src/site/pages-discovery.ts +61 -49
- package/src/site/preview-server.ts +93 -0
- package/src/site/project-sections.ts +48 -0
- package/src/site/prototype-resolver.ts +55 -11
- package/src/site/schema-command.ts +242 -0
- package/src/site/site-build.ts +178 -28
- package/src/site/site-loader.ts +3 -5
- package/src/targets/compile-client.ts +36 -5
- package/src/targets/compile-element.ts +41 -4
- package/src/targets/compile-server.ts +83 -5
- package/src/site/content-loader.ts +0 -385
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/compiler",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Jx static HTML compiler, island detector, and site builder",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -19,15 +19,17 @@
|
|
|
19
19
|
"type": "module",
|
|
20
20
|
"exports": {
|
|
21
21
|
".": "./src/compiler.ts",
|
|
22
|
-
"./content-loader": "./src/site/content-loader.ts",
|
|
23
22
|
"./format-host": "./src/site/format-host.ts",
|
|
23
|
+
"./project-sections": "./src/site/project-sections.ts",
|
|
24
|
+
"./schema-command": "./src/site/schema-command.ts",
|
|
24
25
|
"./context-injection": "./src/site/context-injection.ts",
|
|
25
26
|
"./head-merger": "./src/site/head-merger.ts",
|
|
26
27
|
"./layout-resolver": "./src/site/layout-resolver.ts",
|
|
27
28
|
"./pages-discovery": "./src/site/pages-discovery.ts",
|
|
28
29
|
"./site": "./src/site/site-build.ts",
|
|
29
30
|
"./site-loader": "./src/site/site-loader.ts",
|
|
30
|
-
"./types": "./src/types.ts"
|
|
31
|
+
"./types": "./src/types.ts",
|
|
32
|
+
"./db-push": "./src/site/db-push.ts"
|
|
31
33
|
},
|
|
32
34
|
"publishConfig": {
|
|
33
35
|
"provenance": true
|
|
@@ -42,14 +44,16 @@
|
|
|
42
44
|
"upgrade": "bunx npm-check-updates -u && bun install"
|
|
43
45
|
},
|
|
44
46
|
"dependencies": {
|
|
45
|
-
"@apidevtools/json-schema-ref-parser": "^15.
|
|
46
|
-
"@jxsuite/
|
|
47
|
-
"@jxsuite/runtime": "^
|
|
48
|
-
"@jxsuite/schema": "^
|
|
47
|
+
"@apidevtools/json-schema-ref-parser": "^15.5.0",
|
|
48
|
+
"@jxsuite/create": "^1.1.0",
|
|
49
|
+
"@jxsuite/runtime": "^1.1.0",
|
|
50
|
+
"@jxsuite/schema": "^1.1.0",
|
|
49
51
|
"sharp": "^0.35.3"
|
|
50
52
|
},
|
|
51
53
|
"devDependencies": {
|
|
52
54
|
"@happy-dom/global-registrator": "^20.10.6",
|
|
55
|
+
"@jxsuite/connector": "^0.3.0",
|
|
56
|
+
"@jxsuite/parser": "^1.1.0",
|
|
53
57
|
"@types/mdast": "^4.0.4"
|
|
54
58
|
}
|
|
55
59
|
}
|
package/src/cli.ts
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Jx — Unified CLI for the Jx platform
|
|
4
4
|
*
|
|
5
|
-
* Commands: jx build [project-root] [--verbose] [--no-clean]
|
|
5
|
+
* Commands: jx build [project-root] [--verbose] [--no-clean] jx schema [project-root] jx validate
|
|
6
|
+
* [project-root]
|
|
6
7
|
*
|
|
7
8
|
* @module jx-cli
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
|
-
import { resolve } from "node:path";
|
|
11
|
+
import { relative, resolve } from "node:path";
|
|
11
12
|
|
|
12
13
|
// The CLI body runs inside an async function rather than via top-level await: when a test pulls this
|
|
13
14
|
// Entry in with a dynamic import(), Bun's test runtime drops the continuation after a top-level await
|
|
@@ -21,20 +22,89 @@ async function main() {
|
|
|
21
22
|
console.log(`Usage: jx <command> [options]
|
|
22
23
|
|
|
23
24
|
Commands:
|
|
24
|
-
build [root]
|
|
25
|
+
build [root] Build a Jx site to dist/
|
|
26
|
+
dev [root] Start the dev server (requires @jxsuite/server and Bun)
|
|
27
|
+
preview [root] Serve an already-built dist/ directory
|
|
28
|
+
schema [root] Generate project.schema.json + document.schema.json from project.json#/extensions
|
|
29
|
+
validate [root] Validate project.json against its generated project.schema.json
|
|
30
|
+
db push [root] Sync the data section's tables to their connections (additive-only)
|
|
25
31
|
|
|
26
32
|
Options:
|
|
27
33
|
--verbose Print detailed build progress
|
|
28
|
-
--no-clean Don't clean outDir before building
|
|
34
|
+
--no-clean Don't clean outDir before building
|
|
35
|
+
--port <n> dev/preview: port to listen on (default 3000 / 4173)
|
|
36
|
+
--dry-run db push: print the statements without executing them
|
|
37
|
+
--connection db push: restrict to one connection name`);
|
|
29
38
|
process.exit(0);
|
|
30
39
|
}
|
|
31
40
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
// `db push` is a two-word command; normalize it before the flag/positional split.
|
|
42
|
+
const isDb = command === "db";
|
|
43
|
+
const dbSubcommand = isDb ? args[1] : null;
|
|
44
|
+
const rest = args.slice(isDb ? 2 : 1);
|
|
45
|
+
const flags = new Set<string>();
|
|
46
|
+
const positionals: string[] = [];
|
|
47
|
+
let connectionArg: string | undefined;
|
|
48
|
+
let portArg: number | undefined;
|
|
49
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
50
|
+
const arg = rest[index]!;
|
|
51
|
+
if (arg === "--connection") {
|
|
52
|
+
index += 1;
|
|
53
|
+
connectionArg = rest[index];
|
|
54
|
+
} else if (arg === "--port") {
|
|
55
|
+
index += 1;
|
|
56
|
+
const parsed = Math.trunc(Number(rest[index] ?? ""));
|
|
57
|
+
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
58
|
+
portArg = parsed;
|
|
59
|
+
}
|
|
60
|
+
} else if (arg.startsWith("--")) {
|
|
61
|
+
flags.add(arg);
|
|
62
|
+
} else {
|
|
63
|
+
positionals.push(arg);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const [positional] = positionals;
|
|
67
|
+
const projectRoot = resolve(positional ?? ".");
|
|
36
68
|
|
|
37
|
-
|
|
69
|
+
if (isDb) {
|
|
70
|
+
if (dbSubcommand !== "push") {
|
|
71
|
+
console.error(
|
|
72
|
+
`Unknown db subcommand: ${dbSubcommand ?? "(none)"}\nUsage: jx db push [root] [--dry-run] [--connection <name>]`,
|
|
73
|
+
);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
const dryRun = flags.has("--dry-run");
|
|
77
|
+
try {
|
|
78
|
+
const { dbPush } = await import("./site/db-push.ts");
|
|
79
|
+
const { results, bindingsPatched, wranglerPath } = await dbPush(projectRoot, {
|
|
80
|
+
dryRun,
|
|
81
|
+
...(connectionArg === undefined ? {} : { connection: connectionArg }),
|
|
82
|
+
});
|
|
83
|
+
for (const result of results) {
|
|
84
|
+
const mode = result.applied ? "applied" : "dry-run";
|
|
85
|
+
console.log(
|
|
86
|
+
`${result.connection} (${result.provider}) — ${result.tables.length} table(s), ` +
|
|
87
|
+
`${result.statements.length} statement(s) [${mode}]`,
|
|
88
|
+
);
|
|
89
|
+
for (const statement of result.statements) {
|
|
90
|
+
console.log(` ${statement}`);
|
|
91
|
+
}
|
|
92
|
+
for (const warning of result.warnings) {
|
|
93
|
+
console.warn(` warning: ${warning}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (bindingsPatched && wranglerPath) {
|
|
97
|
+
console.log(`Updated bindings in ${relative(projectRoot, wranglerPath)}`);
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
const err = error as Error;
|
|
101
|
+
console.error(`db push failed: ${err.message}`);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (command === "build") {
|
|
38
108
|
const verbose = flags.has("--verbose");
|
|
39
109
|
const clean = !flags.has("--no-clean");
|
|
40
110
|
|
|
@@ -58,6 +128,59 @@ Options:
|
|
|
58
128
|
console.error(`Build failed: ${err.message}`);
|
|
59
129
|
process.exit(1);
|
|
60
130
|
}
|
|
131
|
+
} else if (command === "dev") {
|
|
132
|
+
try {
|
|
133
|
+
const { runDev } = await import("./site/dev-command.ts");
|
|
134
|
+
runDev(projectRoot, portArg);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const err = error as Error;
|
|
137
|
+
console.error(err.message);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
} else if (command === "preview") {
|
|
141
|
+
const { existsSync } = await import("node:fs");
|
|
142
|
+
const distDir = resolve(projectRoot, "dist");
|
|
143
|
+
if (!existsSync(distDir)) {
|
|
144
|
+
console.error(`No build output at ${distDir} — run \`jx build\` first.`);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
const { startPreviewServer } = await import("./site/preview-server.ts");
|
|
148
|
+
const port = portArg ?? 4173;
|
|
149
|
+
startPreviewServer(distDir, port);
|
|
150
|
+
console.log(`Previewing ${distDir} at http://localhost:${port}`);
|
|
151
|
+
} else if (command === "schema") {
|
|
152
|
+
try {
|
|
153
|
+
const { writeProjectSchemas } = await import("./site/schema-command.ts");
|
|
154
|
+
const { projectSchemaPath, documentSchemaPath } = await writeProjectSchemas(projectRoot);
|
|
155
|
+
console.log(`Wrote ${relative(projectRoot, projectSchemaPath)}`);
|
|
156
|
+
console.log(`Wrote ${relative(projectRoot, documentSchemaPath)}`);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
const err = error as Error;
|
|
159
|
+
console.error(`Schema generation failed: ${err.message}`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
} else if (command === "validate") {
|
|
163
|
+
try {
|
|
164
|
+
const { validateProjectFile } = await import("@jxsuite/schema/validate-project");
|
|
165
|
+
const { valid, errors } = await validateProjectFile(projectRoot);
|
|
166
|
+
if (valid) {
|
|
167
|
+
console.log(`project.json is valid (${projectRoot})`);
|
|
168
|
+
} else {
|
|
169
|
+
console.error(`project.json is INVALID (${projectRoot}):`);
|
|
170
|
+
for (const error of errors ?? []) {
|
|
171
|
+
const { instancePath, message } = (error ?? {}) as {
|
|
172
|
+
instancePath?: string;
|
|
173
|
+
message?: string;
|
|
174
|
+
};
|
|
175
|
+
console.error(` - ${instancePath || "/"}: ${message ?? JSON.stringify(error)}`);
|
|
176
|
+
}
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
const err = error as Error;
|
|
181
|
+
console.error(`Validation failed: ${err.message}`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
61
184
|
} else {
|
|
62
185
|
console.error(`Unknown command: ${command}\nRun "jx --help" for usage.`);
|
|
63
186
|
process.exit(1);
|
package/src/shared.ts
CHANGED
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
|
|
9
9
|
import { RESERVED_KEYS, camelToKebab, toCSSText } from "@jxsuite/runtime";
|
|
10
10
|
import { evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
|
|
11
|
+
import { runStatements } from "@jxsuite/runtime/statements";
|
|
11
12
|
import {
|
|
12
13
|
childrenContainArray,
|
|
14
|
+
hasStructuredBody,
|
|
13
15
|
isExpressionDef,
|
|
14
16
|
isFunctionDef,
|
|
15
17
|
isMappedArray,
|
|
18
|
+
isNamedFormulaDef,
|
|
16
19
|
isPrototypeDef,
|
|
17
20
|
isRef,
|
|
18
21
|
isSchemaOnlyDef as isSchemaOnly,
|
|
@@ -35,6 +38,28 @@ import type {
|
|
|
35
38
|
// Re-export runtime utilities used by submodules
|
|
36
39
|
export { RESERVED_KEYS, camelToKebab, toCSSText } from "@jxsuite/runtime";
|
|
37
40
|
export { compileExpression, evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
|
|
41
|
+
export { compileStatements, runStatements } from "@jxsuite/runtime/statements";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Emit the JS source of a named formula's scope callable: positional arguments are mapped onto the
|
|
45
|
+
* declared parameter names (honoring CemParameter defaults) as the `_args` object the compiled
|
|
46
|
+
* body's `$args/` refs read from. Call sites stay positional, matching the interpreter.
|
|
47
|
+
*/
|
|
48
|
+
export function emitFormulaFn(def: { parameters?: unknown[] }, compiledBody: string): string {
|
|
49
|
+
const entries: string[] = [];
|
|
50
|
+
for (const [i, p] of (def.parameters ?? []).entries()) {
|
|
51
|
+
const name = typeof p === "string" ? p : ((p as { name?: string } | null)?.name ?? "");
|
|
52
|
+
if (!name) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const hasDefault = typeof p === "object" && p !== null && "default" in p;
|
|
56
|
+
const arg = hasDefault
|
|
57
|
+
? `_a[${i}] === undefined ? ${JSON.stringify((p as { default?: unknown }).default)} : _a[${i}]`
|
|
58
|
+
: `_a[${i}]`;
|
|
59
|
+
entries.push(`${JSON.stringify(name)}: ${arg}`);
|
|
60
|
+
}
|
|
61
|
+
return `(..._a) => { const _args = { ${entries.join(", ")} }; return ${compiledBody}; }`;
|
|
62
|
+
}
|
|
38
63
|
export type { ExpressionNode } from "@jxsuite/runtime/expression";
|
|
39
64
|
|
|
40
65
|
// CDN defaults
|
|
@@ -283,7 +308,24 @@ export function buildInitialScope(
|
|
|
283
308
|
}
|
|
284
309
|
if (isExpressionDef(def)) {
|
|
285
310
|
const node = def.$expression as ExpressionNode;
|
|
286
|
-
if (
|
|
311
|
+
if (isNamedFormulaDef(def)) {
|
|
312
|
+
// Named formula: callable, positional args mapped onto its declared parameters.
|
|
313
|
+
const params = def.parameters as (string | { name?: string; default?: unknown })[];
|
|
314
|
+
setOwnScopeValue(scope, key, (...argValues: unknown[]) => {
|
|
315
|
+
const args: Record<string, unknown> = {};
|
|
316
|
+
for (const [i, p] of params.entries()) {
|
|
317
|
+
const name = typeof p === "string" ? p : (p?.name ?? "");
|
|
318
|
+
if (!name) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
args[name] =
|
|
322
|
+
argValues[i] === undefined && typeof p === "object" && p !== null && "default" in p
|
|
323
|
+
? p.default
|
|
324
|
+
: argValues[i];
|
|
325
|
+
}
|
|
326
|
+
return evaluateExpression(node, scope, null, { args });
|
|
327
|
+
});
|
|
328
|
+
} else if (isMutating(node.operator)) {
|
|
287
329
|
setOwnScopeValue(scope, key, (s: Record<string, unknown>, event: Event) =>
|
|
288
330
|
evaluateExpression(node, s, event),
|
|
289
331
|
);
|
|
@@ -293,12 +335,19 @@ export function buildInitialScope(
|
|
|
293
335
|
continue;
|
|
294
336
|
}
|
|
295
337
|
if (isFunctionDef(def)) {
|
|
296
|
-
if (def
|
|
338
|
+
if (hasStructuredBody(def)) {
|
|
339
|
+
// Structured body (spec §20): a side-effecting handler in the build-time scope.
|
|
340
|
+
const { body } = def;
|
|
341
|
+
setOwnScopeValue(scope, key, (s: Record<string, unknown>, event?: Event) => {
|
|
342
|
+
void runStatements(body, s ?? scope, event ?? null);
|
|
343
|
+
});
|
|
344
|
+
} else if (typeof def.body === "string") {
|
|
297
345
|
const names = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
|
|
298
|
-
const
|
|
346
|
+
const { body } = def;
|
|
347
|
+
const fn = new Function("state", ...names, body) as (
|
|
299
348
|
state: Record<string, unknown>,
|
|
300
349
|
) => unknown;
|
|
301
|
-
if (
|
|
350
|
+
if (body.includes("return")) {
|
|
302
351
|
defineLazyScopeValue(scope, key, () => fn(scope));
|
|
303
352
|
} else {
|
|
304
353
|
setOwnScopeValue(scope, key, fn);
|
|
@@ -4,9 +4,6 @@
|
|
|
4
4
|
* Injects project-level and page-level context variables into a page's state before compilation.
|
|
5
5
|
* These are available as $site.* and $page.* in template expressions.
|
|
6
6
|
*
|
|
7
|
-
* Also resolves ContentCollection and ContentEntry $prototype entries against loaded content types
|
|
8
|
-
* (Phase 2, spec §6.4).
|
|
9
|
-
*
|
|
10
7
|
* Per site-architecture spec §10: $site.name — from project.json name $site.url — from project.json
|
|
11
8
|
* url $site.state.* — site-wide reactive state $page.url — current page URL path $page.title — page
|
|
12
9
|
* title $page.params — dynamic route parameters (if any)
|
|
@@ -19,7 +16,6 @@ import type {
|
|
|
19
16
|
JxStateDefinition,
|
|
20
17
|
ProjectConfig,
|
|
21
18
|
} from "@jxsuite/schema/types";
|
|
22
|
-
import type { ContentLoaderEntry } from "@jxsuite/parser/types";
|
|
23
19
|
import type { SiteRoute } from "../types.ts";
|
|
24
20
|
|
|
25
21
|
/**
|
|
@@ -28,7 +24,6 @@ import type { SiteRoute } from "../types.ts";
|
|
|
28
24
|
* @param {JxDocument} doc - The page document (mutated)
|
|
29
25
|
* @param {ProjectConfig} projectConfig - Loaded project configuration
|
|
30
26
|
* @param {SiteRoute} route - The resolved route for this page
|
|
31
|
-
* @param {Map<string, ContentLoaderEntry[]>} [contentTypes] - Loaded content types
|
|
32
27
|
* @param {string | null} [projectRoot] - Absolute path to the project root (for import rebasing)
|
|
33
28
|
* @returns {JxDocument} The mutated document
|
|
34
29
|
*/
|
|
@@ -36,7 +31,6 @@ export function injectContext(
|
|
|
36
31
|
doc: JxDocument,
|
|
37
32
|
projectConfig: ProjectConfig,
|
|
38
33
|
route: SiteRoute,
|
|
39
|
-
_contentTypes = new Map<string, ContentLoaderEntry[]>(),
|
|
40
34
|
projectRoot: string | null = null,
|
|
41
35
|
) {
|
|
42
36
|
if (!doc.state) {
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Db-push — `jx db push [root] [--dry-run] [--connection <name>]` (plan Part 4a).
|
|
3
|
+
*
|
|
4
|
+
* Registry-driven: every `connections` entry resolves to its connector class (by
|
|
5
|
+
* `connector.provider`) and the additive schema sync runs through the class's `deploySchema`
|
|
6
|
+
* capability with the connection's tables from the `data` section. The environment is process.env
|
|
7
|
+
* merged under `<root>/.dev.vars` (wrangler convention, specs/extensions.md §13). After a non-dry
|
|
8
|
+
* apply, each connector's `bindings` fragment is deep-merged into wrangler.jsonc via
|
|
9
|
+
* `applyBindingFragments` (user keys preserved).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
import { applyBindingFragments } from "@jxsuite/create/scaffold";
|
|
15
|
+
import { buildProjectExtensionRegistry } from "./format-host.ts";
|
|
16
|
+
import { loadProjectConfig } from "./site-loader.ts";
|
|
17
|
+
import type { FormatEntry } from "@jxsuite/schema/format-registry";
|
|
18
|
+
|
|
19
|
+
export interface DbPushOptions {
|
|
20
|
+
dryRun?: boolean;
|
|
21
|
+
/** Restrict the push to one connection name. */
|
|
22
|
+
connection?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ConnectionPushResult {
|
|
26
|
+
connection: string;
|
|
27
|
+
provider: string;
|
|
28
|
+
tables: string[];
|
|
29
|
+
statements: string[];
|
|
30
|
+
warnings: string[];
|
|
31
|
+
applied: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DbPushResult {
|
|
35
|
+
results: ConnectionPushResult[];
|
|
36
|
+
/** True when wrangler.jsonc was rewritten with binding fragments. */
|
|
37
|
+
bindingsPatched: boolean;
|
|
38
|
+
wranglerPath: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface DeployResultLike {
|
|
42
|
+
statements?: string[];
|
|
43
|
+
warnings?: string[];
|
|
44
|
+
applied?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Push the project's table schemas to their connections' databases.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} projectRoot - Absolute path to the project directory
|
|
51
|
+
* @param {DbPushOptions} [options]
|
|
52
|
+
* @returns {Promise<DbPushResult>}
|
|
53
|
+
*/
|
|
54
|
+
export async function dbPush(
|
|
55
|
+
projectRoot: string,
|
|
56
|
+
options: DbPushOptions = {},
|
|
57
|
+
): Promise<DbPushResult> {
|
|
58
|
+
const { dryRun = false, connection: only } = options;
|
|
59
|
+
const { config } = loadProjectConfig(projectRoot);
|
|
60
|
+
const registry = await buildProjectExtensionRegistry(projectRoot, config);
|
|
61
|
+
|
|
62
|
+
const connections = (config.connections ?? {}) as Record<string, Record<string, unknown>>;
|
|
63
|
+
const tables = (config.data ?? {}) as Record<string, { connection?: string }>;
|
|
64
|
+
|
|
65
|
+
const names = Object.keys(connections).filter((name) => !only || name === only);
|
|
66
|
+
if (only && names.length === 0) {
|
|
67
|
+
throw new Error(`Unknown connection "${only}" (no such connections entry in project.json)`);
|
|
68
|
+
}
|
|
69
|
+
if (names.length === 0) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`project.json has no "connections" section — nothing to push. Declare a connection and ` +
|
|
72
|
+
`its tables in the "data" section first.`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const env: Record<string, unknown> = {
|
|
77
|
+
...process.env,
|
|
78
|
+
...readDevVars(projectRoot),
|
|
79
|
+
JX_PROJECT_ROOT: projectRoot,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const results: ConnectionPushResult[] = [];
|
|
83
|
+
const fragments: Record<string, unknown>[] = [];
|
|
84
|
+
for (const name of names) {
|
|
85
|
+
const connection: Record<string, unknown> = { ...connections[name], $name: name };
|
|
86
|
+
const provider = String(connection.provider ?? "");
|
|
87
|
+
const entry = connectorByProvider(registry.connectors(), provider);
|
|
88
|
+
if (!entry) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Connection "${name}" names provider "${provider}", but no enabled extension provides ` +
|
|
91
|
+
`it (enable the extension in project.json "extensions")`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const connectionTables: Record<string, unknown> = {};
|
|
95
|
+
for (const [tableName, def] of Object.entries(tables)) {
|
|
96
|
+
if (def.connection === name) {
|
|
97
|
+
connectionTables[tableName] = def;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const deployed = (await entry.call("deploySchema", connectionTables, connection, {
|
|
102
|
+
dryRun,
|
|
103
|
+
env,
|
|
104
|
+
})) as DeployResultLike;
|
|
105
|
+
results.push({
|
|
106
|
+
applied: deployed.applied ?? !dryRun,
|
|
107
|
+
connection: name,
|
|
108
|
+
provider,
|
|
109
|
+
statements: deployed.statements ?? [],
|
|
110
|
+
tables: Object.keys(connectionTables),
|
|
111
|
+
warnings: deployed.warnings ?? [],
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (entry.capabilities.bindings) {
|
|
115
|
+
const fragment = (await entry.call("bindings", connection)) as Record<string, unknown>;
|
|
116
|
+
if (fragment && Object.keys(fragment).length > 0) {
|
|
117
|
+
fragments.push(fragment);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Apply binding fragments to wrangler.jsonc (skipped on dry runs and non-wrangler projects).
|
|
123
|
+
const wranglerPath = resolve(projectRoot, "wrangler.jsonc");
|
|
124
|
+
let bindingsPatched = false;
|
|
125
|
+
if (!dryRun && fragments.length > 0 && existsSync(wranglerPath)) {
|
|
126
|
+
const existing = readFileSync(wranglerPath, "utf8");
|
|
127
|
+
const { content, patched } = applyBindingFragments(existing, fragments);
|
|
128
|
+
if (patched && content !== existing) {
|
|
129
|
+
writeFileSync(wranglerPath, content, "utf8");
|
|
130
|
+
bindingsPatched = true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { bindingsPatched, results, wranglerPath: existsSync(wranglerPath) ? wranglerPath : null };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Find the connector class serving a provider id. */
|
|
138
|
+
function connectorByProvider(entries: FormatEntry[], provider: string): FormatEntry | undefined {
|
|
139
|
+
return entries.find((entry) => entry.connector?.provider === provider);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse `<root>/.dev.vars` (wrangler convention): KEY=VALUE lines, `#` comments, optional
|
|
144
|
+
* single/double quotes. Kept deliberately tiny; the dev server has its own copy in
|
|
145
|
+
* `@jxsuite/server` (dev-vars.ts) — the compiler cannot depend on the server package.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} projectRoot
|
|
148
|
+
* @returns {Record<string, string>}
|
|
149
|
+
*/
|
|
150
|
+
export function readDevVars(projectRoot: string): Record<string, string> {
|
|
151
|
+
const path = resolve(projectRoot, ".dev.vars");
|
|
152
|
+
if (!existsSync(path)) {
|
|
153
|
+
return {};
|
|
154
|
+
}
|
|
155
|
+
const out: Record<string, string> = {};
|
|
156
|
+
for (const rawLine of readFileSync(path, "utf8").split("\n")) {
|
|
157
|
+
const line = rawLine.trim();
|
|
158
|
+
if (line === "" || line.startsWith("#")) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const eq = line.indexOf("=");
|
|
162
|
+
if (eq === -1) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const key = line.slice(0, eq).trim();
|
|
166
|
+
let value = line.slice(eq + 1).trim();
|
|
167
|
+
if (
|
|
168
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
169
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
170
|
+
) {
|
|
171
|
+
value = value.slice(1, -1);
|
|
172
|
+
}
|
|
173
|
+
if (key !== "") {
|
|
174
|
+
out[key] = value;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev command — `jx dev` resolves the project's @jxsuite/server dev entry and spawns it under Bun.
|
|
3
|
+
*
|
|
4
|
+
* The compiler cannot depend on @jxsuite/server (the server already depends on the compiler), and
|
|
5
|
+
* the dev server is Bun-native while the jx bin runs under Node — so the command is pure resolution
|
|
6
|
+
* + spawn: the entry module is resolved from the PROJECT's node_modules and executed with Bun.
|
|
7
|
+
*
|
|
8
|
+
* @module dev-command
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createRequire } from "node:module";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import type { ChildProcess } from "node:child_process";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the project's `@jxsuite/server/dev` entry module.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} projectRoot - Absolute project root
|
|
20
|
+
* @returns {string} Absolute path to the dev entry module
|
|
21
|
+
* @throws {Error} With an install hint when the package is not present
|
|
22
|
+
*/
|
|
23
|
+
export function resolveDevEntry(projectRoot: string): string {
|
|
24
|
+
try {
|
|
25
|
+
const projectRequire = createRequire(join(projectRoot, "package.json"));
|
|
26
|
+
return projectRequire.resolve("@jxsuite/server/dev");
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`@jxsuite/server is not installed in ${projectRoot}. ` +
|
|
30
|
+
`Add it to devDependencies (e.g. \`bun add -d @jxsuite/server\`) and retry.`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The slice of `process` runDev touches — injectable for tests. */
|
|
36
|
+
export interface ProcessLike {
|
|
37
|
+
on: (event: string, handler: () => void) => unknown;
|
|
38
|
+
exitCode?: number | string | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Spawn the dev server under Bun with inherited stdio.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} projectRoot - Absolute project root
|
|
45
|
+
* @param {number | undefined} port - Optional port override
|
|
46
|
+
* @param {typeof spawn} [spawnImpl] - Injectable for tests
|
|
47
|
+
* @param {ProcessLike} [proc] - Injectable for tests
|
|
48
|
+
* @returns {ChildProcess} The spawned Bun process
|
|
49
|
+
*/
|
|
50
|
+
export function runDev(
|
|
51
|
+
projectRoot: string,
|
|
52
|
+
port: number | undefined,
|
|
53
|
+
spawnImpl: typeof spawn = spawn,
|
|
54
|
+
proc: ProcessLike = process as unknown as ProcessLike,
|
|
55
|
+
): ChildProcess {
|
|
56
|
+
const entry = resolveDevEntry(projectRoot);
|
|
57
|
+
const args = [entry, "--root", projectRoot];
|
|
58
|
+
if (port !== undefined) {
|
|
59
|
+
args.push("--port", String(port));
|
|
60
|
+
}
|
|
61
|
+
const child = spawnImpl("bun", args, { stdio: "inherit" });
|
|
62
|
+
child.on("error", (error: NodeJS.ErrnoException) => {
|
|
63
|
+
if (error.code === "ENOENT") {
|
|
64
|
+
console.error(
|
|
65
|
+
"The Jx dev server runs on Bun, which was not found on your PATH. " +
|
|
66
|
+
"Install it from https://bun.sh and retry.",
|
|
67
|
+
);
|
|
68
|
+
} else {
|
|
69
|
+
console.error(`Failed to start the dev server: ${error.message}`);
|
|
70
|
+
}
|
|
71
|
+
proc.exitCode = 1;
|
|
72
|
+
});
|
|
73
|
+
// Ctrl+C reaches the child through the shared process group; also mirror explicit kills.
|
|
74
|
+
proc.on("SIGINT", () => {
|
|
75
|
+
child.kill("SIGINT");
|
|
76
|
+
});
|
|
77
|
+
proc.on("SIGTERM", () => {
|
|
78
|
+
child.kill("SIGTERM");
|
|
79
|
+
});
|
|
80
|
+
child.on("exit", (code) => {
|
|
81
|
+
proc.exitCode = code ?? 0;
|
|
82
|
+
});
|
|
83
|
+
return child;
|
|
84
|
+
}
|