@junejs/server 0.0.9 → 0.0.11
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 +8 -3
- package/src/adapter.ts +226 -0
- package/src/app.ts +65 -3
- package/src/blob.ts +1 -1
- package/src/build.ts +178 -55
- package/src/client-bundle.ts +24 -11
- package/src/css-modules-ambient.d.ts +9 -0
- package/src/css-modules-loader.d.mts +7 -0
- package/src/css-modules-loader.mjs +20 -0
- package/src/css-modules.ts +185 -0
- package/src/css.ts +118 -0
- package/src/d1-migrate.ts +209 -0
- package/src/d1.ts +53 -0
- package/src/db.ts +8 -47
- package/src/deploy.ts +121 -21
- package/src/dev-reload.ts +56 -10
- package/src/dev.ts +32 -1
- package/src/host.ts +7 -78
- package/src/index.ts +28 -1
- package/src/kv.ts +1 -1
- package/src/migrate.ts +122 -0
- package/src/pipeline.ts +119 -18
- package/src/resources.ts +47 -0
- package/src/sqlite-driver.ts +160 -0
- package/src/worker.ts +41 -13
package/src/deploy.ts
CHANGED
|
@@ -1,35 +1,114 @@
|
|
|
1
|
-
// `june deploy` — ship a June app.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// adapter, not the CLI. --dry-run is the CI test (build + wrangler validate,
|
|
7
|
-
// no upload).
|
|
1
|
+
// `june deploy` — ship a June app. The verb is fixed; the TARGET is the adapter
|
|
2
|
+
// (same seam philosophy as JuneHost). `june build` already produced the target's
|
|
3
|
+
// artifacts (workers → worker.js + wrangler.jsonc; vercel → .vercel/output/), so
|
|
4
|
+
// deploy just runs the platform CLI: wrangler for Workers (+ D1 migration first),
|
|
5
|
+
// or `vercel deploy --prebuilt` for Vercel. --dry-run validates without uploading.
|
|
8
6
|
import { existsSync } from "node:fs";
|
|
9
7
|
import { join } from "node:path";
|
|
10
8
|
|
|
11
9
|
import { juneBuild } from "./build";
|
|
12
10
|
import { loadJuneConfig } from "./config-loader";
|
|
11
|
+
import { blockedMessage } from "./migrate";
|
|
12
|
+
import { migrateD1, resolveD1Database, type D1Exec } from "./d1-migrate";
|
|
13
|
+
|
|
14
|
+
// One platform-CLI invocation, factored out so tests can drive juneDeploy's
|
|
15
|
+
// orchestration (target dispatch, migrate-before-deploy ordering, the
|
|
16
|
+
// dry-run/destructive gates) without spawning wrangler or vercel.
|
|
17
|
+
type CliRun = (
|
|
18
|
+
args: string[],
|
|
19
|
+
cwd: string,
|
|
20
|
+
) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
|
|
21
|
+
|
|
22
|
+
const spawnCli: CliRun = async (args, cwd) => {
|
|
23
|
+
const proc = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" });
|
|
24
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
25
|
+
new Response(proc.stdout).text(),
|
|
26
|
+
new Response(proc.stderr).text(),
|
|
27
|
+
proc.exited,
|
|
28
|
+
]);
|
|
29
|
+
return { stdout, stderr, exitCode };
|
|
30
|
+
};
|
|
13
31
|
|
|
14
32
|
export type DeployResult = {
|
|
15
33
|
url: string | null;
|
|
16
34
|
dryRun: boolean;
|
|
17
|
-
configPath: string;
|
|
35
|
+
configPath: string; // wrangler config (workers) or the Build Output dir (vercel)
|
|
36
|
+
migrated: string[]; // D1 migrations applied this deploy (empty for vercel / dry-run)
|
|
18
37
|
};
|
|
19
38
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
39
|
+
type DeployOptions = {
|
|
40
|
+
dryRun?: boolean;
|
|
41
|
+
skipBuild?: boolean;
|
|
42
|
+
skipMigrate?: boolean;
|
|
43
|
+
allowDestructive?: boolean;
|
|
44
|
+
prod?: boolean; // vercel: deploy to production (default: a preview deploy)
|
|
45
|
+
d1Exec?: D1Exec; // test seam: the D1 transport
|
|
46
|
+
runCli?: CliRun; // test seam: the platform CLI invocation
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export async function juneDeploy(appRoot: string, options: DeployOptions = {}): Promise<DeployResult> {
|
|
24
50
|
const cfg = await loadJuneConfig(appRoot);
|
|
25
|
-
|
|
26
|
-
|
|
51
|
+
// The adapter is the source of truth for the target; deploy.target is a legacy
|
|
52
|
+
// fallback. workers() is the default when neither names one.
|
|
53
|
+
const adapterName = (cfg.deploy?.adapter as { name?: string } | undefined)?.name;
|
|
54
|
+
const target = adapterName ?? cfg.deploy?.target ?? "workers";
|
|
55
|
+
if (target !== "workers" && target !== "vercel") {
|
|
56
|
+
throw new Error(`unknown deploy target: ${target}`);
|
|
57
|
+
}
|
|
27
58
|
|
|
28
59
|
if (!options.skipBuild) {
|
|
29
60
|
const built = await juneBuild(appRoot);
|
|
30
61
|
console.log(`built ${built.outFile}`);
|
|
31
62
|
}
|
|
32
63
|
|
|
64
|
+
const run = options.runCli ?? spawnCli;
|
|
65
|
+
return target === "vercel"
|
|
66
|
+
? deployVercel(appRoot, options, run)
|
|
67
|
+
: deployWorkers(appRoot, cfg, options, run);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- Vercel: upload the prebuilt Build Output (.vercel/output/) ---------------
|
|
71
|
+
async function deployVercel(appRoot: string, options: DeployOptions, run: CliRun): Promise<DeployResult> {
|
|
72
|
+
const out = join(appRoot, ".vercel", "output");
|
|
73
|
+
if (!existsSync(join(out, "config.json"))) {
|
|
74
|
+
throw new Error(`no Vercel Build Output found (expected ${out}/config.json) — run june build first`);
|
|
75
|
+
}
|
|
76
|
+
if (options.dryRun) {
|
|
77
|
+
console.log(`dry-run: built ${out} (skipping vercel deploy)`);
|
|
78
|
+
return { url: null, dryRun: true, configPath: out, migrated: [] };
|
|
79
|
+
}
|
|
80
|
+
if (!process.env.VERCEL_TOKEN) {
|
|
81
|
+
console.log(
|
|
82
|
+
"note: VERCEL_TOKEN not set — the vercel CLI will use its own login\n" +
|
|
83
|
+
" (run `bunx vercel login` once, or export the token).",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
// --prebuilt uploads .vercel/output as-is (no second build); --yes is
|
|
87
|
+
// non-interactive; --prod targets production (default is a preview deploy).
|
|
88
|
+
const args = ["bunx", "vercel@latest", "deploy", "--prebuilt", "--yes"];
|
|
89
|
+
if (options.prod) args.push("--prod");
|
|
90
|
+
|
|
91
|
+
const { stdout, stderr, exitCode } = await run(args, appRoot);
|
|
92
|
+
if (stdout.trim()) console.log(stdout.trimEnd());
|
|
93
|
+
if (exitCode !== 0) {
|
|
94
|
+
if (/credentials|token|forbidden|unauthorized/i.test(stderr + stdout)) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
"vercel authentication failed — set VERCEL_TOKEN or run `bunx vercel login`.\n" + stderr.trim(),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`vercel deploy failed (exit ${exitCode})\n${stderr.trim()}`);
|
|
100
|
+
}
|
|
101
|
+
const url = stdout.match(/https:\/\/\S+\.vercel\.app\S*/)?.[0] ?? null;
|
|
102
|
+
return { url, dryRun: false, configPath: out, migrated: [] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- Workers: D1 migration (if any), then wrangler deploy --------------------
|
|
106
|
+
async function deployWorkers(
|
|
107
|
+
appRoot: string,
|
|
108
|
+
cfg: Awaited<ReturnType<typeof loadJuneConfig>>,
|
|
109
|
+
options: DeployOptions,
|
|
110
|
+
run: CliRun,
|
|
111
|
+
): Promise<DeployResult> {
|
|
33
112
|
// The app's own wrangler config wins; otherwise the one `june build` emits next
|
|
34
113
|
// to the bundle (its `main` is relative to the config file).
|
|
35
114
|
const configPath =
|
|
@@ -39,6 +118,32 @@ export async function juneDeploy(
|
|
|
39
118
|
throw new Error(`no wrangler config found (expected ${configPath}) — run june build first`);
|
|
40
119
|
}
|
|
41
120
|
|
|
121
|
+
// Apply pending migrations to the production D1 BEFORE shipping the new worker,
|
|
122
|
+
// so the deployed code finds the schema it expects. Same migrate() (ledger +
|
|
123
|
+
// destructive gate) as dev — a destructive migration halts the deploy until you
|
|
124
|
+
// re-run with --allow-destructive. Skipped on --dry-run (never touches remote
|
|
125
|
+
// state) and for apps with no declared db.
|
|
126
|
+
const migrated: string[] = [];
|
|
127
|
+
if (cfg.resources?.db && !options.skipMigrate && !options.dryRun) {
|
|
128
|
+
const database = await resolveD1Database(appRoot, configPath);
|
|
129
|
+
if (database) {
|
|
130
|
+
const r = await migrateD1({
|
|
131
|
+
appRoot,
|
|
132
|
+
database,
|
|
133
|
+
configPath,
|
|
134
|
+
allowDestructive: options.allowDestructive,
|
|
135
|
+
exec: options.d1Exec,
|
|
136
|
+
});
|
|
137
|
+
if (r.blocked) throw new Error(`D1 ${database}: ${blockedMessage(r.blocked)}`);
|
|
138
|
+
migrated.push(...r.applied);
|
|
139
|
+
console.log(
|
|
140
|
+
r.applied.length
|
|
141
|
+
? `migrated D1 ${database}: ${r.applied.join(", ")}`
|
|
142
|
+
: `D1 ${database}: migrations up to date`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
42
147
|
if (!options.dryRun && !process.env.CLOUDFLARE_API_TOKEN) {
|
|
43
148
|
console.log(
|
|
44
149
|
"note: CLOUDFLARE_API_TOKEN not set — wrangler will fall back to its own login\n" +
|
|
@@ -50,12 +155,7 @@ export async function juneDeploy(
|
|
|
50
155
|
const args = ["bunx", "wrangler@4.99.0", "deploy", "--config", configPath];
|
|
51
156
|
if (options.dryRun) args.push("--dry-run");
|
|
52
157
|
|
|
53
|
-
const
|
|
54
|
-
const [out, err, code] = await Promise.all([
|
|
55
|
-
new Response(proc.stdout).text(),
|
|
56
|
-
new Response(proc.stderr).text(),
|
|
57
|
-
proc.exited,
|
|
58
|
-
]);
|
|
158
|
+
const { stdout: out, stderr: err, exitCode: code } = await run(args, appRoot);
|
|
59
159
|
if (out.trim()) console.log(out.trimEnd());
|
|
60
160
|
if (code !== 0) {
|
|
61
161
|
if (/authentication|login|10000/i.test(err + out)) {
|
|
@@ -68,5 +168,5 @@ export async function juneDeploy(
|
|
|
68
168
|
}
|
|
69
169
|
|
|
70
170
|
const url = out.match(/https:\/\/\S+\.workers\.dev\S*/)?.[0] ?? null;
|
|
71
|
-
return { url, dryRun: options.dryRun ?? false, configPath };
|
|
171
|
+
return { url, dryRun: options.dryRun ?? false, configPath, migrated };
|
|
72
172
|
}
|
package/src/dev-reload.ts
CHANGED
|
@@ -12,15 +12,30 @@
|
|
|
12
12
|
const EVENTS_PATH = "/__june/events";
|
|
13
13
|
const SCRIPT_PATH = "/__june/reload.js";
|
|
14
14
|
|
|
15
|
-
const RELOAD_JS = `// june dev live-reload: reconnect-after-drop →
|
|
15
|
+
const RELOAD_JS = `// june dev live-reload: reconnect-after-drop → reload; "css" event → hot-swap
|
|
16
16
|
(() => {
|
|
17
17
|
let dropped = false;
|
|
18
|
+
const swapCss = () => {
|
|
19
|
+
document.querySelectorAll('link[rel="stylesheet"]').forEach((old) => {
|
|
20
|
+
const u = new URL(old.href);
|
|
21
|
+
if (u.pathname !== "/_june/global.css") return;
|
|
22
|
+
// Clone with a cache-busted href; drop the old one once the new loads so
|
|
23
|
+
// there's no flash of unstyled content. No page reload → island state and
|
|
24
|
+
// scroll survive.
|
|
25
|
+
const next = old.cloneNode();
|
|
26
|
+
u.searchParams.set("t", Date.now());
|
|
27
|
+
next.href = u.pathname + u.search;
|
|
28
|
+
next.addEventListener("load", () => old.remove(), { once: true });
|
|
29
|
+
old.parentNode.insertBefore(next, old.nextSibling);
|
|
30
|
+
});
|
|
31
|
+
};
|
|
18
32
|
const connect = () => {
|
|
19
33
|
const es = new EventSource(${JSON.stringify(EVENTS_PATH)});
|
|
20
34
|
es.addEventListener("open", () => {
|
|
21
35
|
if (dropped) location.reload();
|
|
22
36
|
dropped = false;
|
|
23
37
|
});
|
|
38
|
+
es.addEventListener("css", swapCss); // stylesheet edit → swap, no reload
|
|
24
39
|
es.addEventListener("error", () => {
|
|
25
40
|
dropped = true;
|
|
26
41
|
// EventSource auto-retries while the connection is flaky, but goes to
|
|
@@ -36,14 +51,34 @@ const RELOAD_JS = `// june dev live-reload: reconnect-after-drop → location.re
|
|
|
36
51
|
})();
|
|
37
52
|
`;
|
|
38
53
|
|
|
54
|
+
// Live SSE connections, so a CSS edit can be PUSHED to every open browser (a
|
|
55
|
+
// code edit still rides the restart-then-reconnect path). The child watches the
|
|
56
|
+
// stylesheet (dev.ts) and calls notifyCssChange; the supervisor ignores .css so
|
|
57
|
+
// it does NOT restart — that's what keeps this a hot-swap instead of a reload.
|
|
58
|
+
const clients = new Set<ReadableStreamDefaultController<Uint8Array>>();
|
|
59
|
+
const enc = new TextEncoder();
|
|
60
|
+
|
|
61
|
+
export function notifyCssChange(): void {
|
|
62
|
+
const msg = enc.encode("event: css\ndata: 1\n\n");
|
|
63
|
+
for (const c of clients) {
|
|
64
|
+
try {
|
|
65
|
+
c.enqueue(msg);
|
|
66
|
+
} catch {
|
|
67
|
+
clients.delete(c);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
39
72
|
function devEvents(): Response {
|
|
40
73
|
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
74
|
+
let self: ReadableStreamDefaultController<Uint8Array> | undefined;
|
|
41
75
|
const stream = new ReadableStream<Uint8Array>({
|
|
42
76
|
start(controller) {
|
|
77
|
+
self = controller;
|
|
78
|
+
clients.add(controller);
|
|
43
79
|
// One greeting so the browser fires `open`, then a comment heartbeat:
|
|
44
80
|
// a silent stream gets culled by idle timeouts (hosts, proxies), and a
|
|
45
81
|
// culled stream reads as a restart to the client — which reloads.
|
|
46
|
-
const enc = new TextEncoder();
|
|
47
82
|
controller.enqueue(enc.encode("retry: 300\n\ndata: connected\n\n"));
|
|
48
83
|
heartbeat = setInterval(() => {
|
|
49
84
|
try {
|
|
@@ -55,6 +90,7 @@ function devEvents(): Response {
|
|
|
55
90
|
},
|
|
56
91
|
cancel() {
|
|
57
92
|
clearInterval(heartbeat);
|
|
93
|
+
if (self) clients.delete(self);
|
|
58
94
|
},
|
|
59
95
|
});
|
|
60
96
|
return new Response(stream, {
|
|
@@ -77,14 +113,24 @@ export function withLiveReload(
|
|
|
77
113
|
}
|
|
78
114
|
|
|
79
115
|
const res = await fetchApp(req);
|
|
80
|
-
if (!res.headers.get("content-type")?.includes("text/html")) return res;
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
116
|
+
if (!res.headers.get("content-type")?.includes("text/html") || !res.body) return res;
|
|
117
|
+
// Append the reload script WITHOUT buffering — streaming routes must stay
|
|
118
|
+
// streamed in dev. The trailing <script defer> runs after the document
|
|
119
|
+
// parses regardless of position, so a final chunk is correct and keeps the
|
|
120
|
+
// shell-first flush intact.
|
|
121
|
+
const tag = new TextEncoder().encode(`<script src="${SCRIPT_PATH}" defer></script>`);
|
|
122
|
+
const tagged = res.body.pipeThrough(
|
|
123
|
+
new TransformStream<Uint8Array, Uint8Array>({
|
|
124
|
+
transform(chunk, controller) {
|
|
125
|
+
controller.enqueue(chunk);
|
|
126
|
+
},
|
|
127
|
+
flush(controller) {
|
|
128
|
+
controller.enqueue(tag);
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
86
132
|
const headers = new Headers(res.headers);
|
|
87
|
-
headers.delete("content-length"); // the body
|
|
88
|
-
return new Response(
|
|
133
|
+
headers.delete("content-length"); // the body grew; let the host recompute
|
|
134
|
+
return new Response(tagged, { status: res.status, headers });
|
|
89
135
|
};
|
|
90
136
|
}
|
package/src/dev.ts
CHANGED
|
@@ -4,13 +4,17 @@
|
|
|
4
4
|
// work), load june.config.ts from the app root (the config the PoC forgot to
|
|
5
5
|
// read), build the app, and serve through the detected JuneHost.
|
|
6
6
|
|
|
7
|
+
import { watch } from "node:fs";
|
|
7
8
|
import { createServer as createNetServer } from "node:net";
|
|
9
|
+
import { dirname } from "node:path";
|
|
8
10
|
|
|
9
11
|
import { loadJuneConfig } from "./config-loader";
|
|
10
12
|
import { installAsyncContext } from "./instrumentation";
|
|
11
13
|
import { createApp } from "./app";
|
|
12
|
-
import { withLiveReload } from "./dev-reload";
|
|
14
|
+
import { withLiveReload, notifyCssChange } from "./dev-reload";
|
|
13
15
|
import { host as defaultHost, type JuneHost, type ServeHandle } from "./host";
|
|
16
|
+
import { migrateApp, blockedMessage } from "./migrate";
|
|
17
|
+
import { findGlobalCss, processCssCached, invalidateCss } from "./css";
|
|
14
18
|
|
|
15
19
|
export type DevServerOptions = {
|
|
16
20
|
appDir: string;
|
|
@@ -42,6 +46,14 @@ export async function startDevServer({
|
|
|
42
46
|
}: DevServerOptions): Promise<DevServer> {
|
|
43
47
|
await installAsyncContext();
|
|
44
48
|
const config = await loadJuneConfig(appDir);
|
|
49
|
+
|
|
50
|
+
// Apply pending migrations before serving — dev auto-applies the SAFE ones; a
|
|
51
|
+
// destructive one is reported and skipped (the server still starts, but the
|
|
52
|
+
// route using the new schema will fail until you run it explicitly).
|
|
53
|
+
const m = await migrateApp(dirname(appDir), config);
|
|
54
|
+
if (m?.applied.length) console.log(`[june] migrated: ${m.applied.join(", ")}`);
|
|
55
|
+
if (m?.blocked) console.warn(`[june] ${blockedMessage(m.blocked)}`);
|
|
56
|
+
|
|
45
57
|
const app = createApp({ appDir, config });
|
|
46
58
|
await app.warmup();
|
|
47
59
|
|
|
@@ -56,6 +68,25 @@ export async function startDevServer({
|
|
|
56
68
|
earlyHints: () => app.earlyHints(),
|
|
57
69
|
});
|
|
58
70
|
|
|
71
|
+
// CSS hot-swap: a stylesheet edit pushes a `css` event to open browsers, which
|
|
72
|
+
// re-fetch /global.css and swap the <link> WITHOUT reloading (island state +
|
|
73
|
+
// scroll survive). The supervisor ignores .css so it won't restart over it; a
|
|
74
|
+
// .tsx edit still restarts → full reload (its markup changed too).
|
|
75
|
+
if (findGlobalCss(appDir)) {
|
|
76
|
+
watch(appDir, { recursive: true }, (_event, file) => {
|
|
77
|
+
if (file && file.endsWith(".css")) {
|
|
78
|
+
invalidateCss(); // next /global.css recompiles fresh
|
|
79
|
+
notifyCssChange();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
// Warm + cache the stylesheet in the background: Tailwind v4's native engine
|
|
83
|
+
// costs ~700ms to load and ~145ms to build its design system ONCE. Doing it
|
|
84
|
+
// now (while the user reads the dev URL) overlaps that with startup AND
|
|
85
|
+
// populates the cache, so the first page's stylesheet is instant — and every
|
|
86
|
+
// later navigation serves from cache (recompile only after an edit).
|
|
87
|
+
void processCssCached(appDir).catch(() => {});
|
|
88
|
+
}
|
|
89
|
+
|
|
59
90
|
const url = `http://localhost:${handle.port}`;
|
|
60
91
|
console.log(`june dev → ${url} (host: ${host.name})`);
|
|
61
92
|
return { ...handle, url };
|
package/src/host.ts
CHANGED
|
@@ -25,6 +25,11 @@ import { Readable } from "node:stream";
|
|
|
25
25
|
import type { JuneDb, RunResult } from "@junejs/core/resources";
|
|
26
26
|
export type { JuneDb, RunResult };
|
|
27
27
|
|
|
28
|
+
// Local-sqlite driver selection lives in its own layer; the host just delegates
|
|
29
|
+
// openDb to it (bun:sqlite under Bun, node:sqlite under Node, with guidance when
|
|
30
|
+
// Node is too old). See src/sqlite-driver.ts.
|
|
31
|
+
import { openLocalSqlite } from "./sqlite-driver";
|
|
32
|
+
|
|
28
33
|
export type ServeHandle = { port: number; stop(force?: boolean): void };
|
|
29
34
|
|
|
30
35
|
export type SpawnedModule = {
|
|
@@ -49,59 +54,6 @@ export interface JuneHost {
|
|
|
49
54
|
openDb(path: string): Promise<JuneDb>;
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
// --- a tiny sync→async SQLite adapter shared by both hosts ------------------
|
|
53
|
-
|
|
54
|
-
// The shape bun:sqlite exposes directly and node:sqlite is adapted to: a
|
|
55
|
-
// prepared statement with positional binding.
|
|
56
|
-
type SyncStatement = {
|
|
57
|
-
all(...params: unknown[]): unknown[];
|
|
58
|
-
get(...params: unknown[]): unknown;
|
|
59
|
-
run(...params: unknown[]): { changes: number | bigint; lastInsertRowid?: number | bigint };
|
|
60
|
-
};
|
|
61
|
-
type SyncSqlite = {
|
|
62
|
-
query(sql: string): SyncStatement;
|
|
63
|
-
exec(sql: string): void;
|
|
64
|
-
close(): void;
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
// Wrap a synchronous SQLite handle as the async JuneDb. The driver work is
|
|
68
|
-
// synchronous, but the SURFACE is async, so swapping in D1 later is invisible
|
|
69
|
-
// to every caller.
|
|
70
|
-
function asyncSqlite(db: SyncSqlite): JuneDb {
|
|
71
|
-
const self: JuneDb = {
|
|
72
|
-
async query<T>(sql: string, params: unknown[] = []) {
|
|
73
|
-
return db.query(sql).all(...params) as T[];
|
|
74
|
-
},
|
|
75
|
-
async get<T>(sql: string, params: unknown[] = []) {
|
|
76
|
-
// Normalize "no row" to undefined — bun:sqlite returns null, node:sqlite
|
|
77
|
-
// returns undefined; the seam hides the difference.
|
|
78
|
-
return (db.query(sql).get(...params) ?? undefined) as T | undefined;
|
|
79
|
-
},
|
|
80
|
-
async run(sql: string, params: unknown[] = []) {
|
|
81
|
-
const r = db.query(sql).run(...params);
|
|
82
|
-
return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid ?? 0 };
|
|
83
|
-
},
|
|
84
|
-
async exec(sql: string) {
|
|
85
|
-
db.exec(sql);
|
|
86
|
-
},
|
|
87
|
-
async transaction<T>(fn: (tx: JuneDb) => Promise<T>) {
|
|
88
|
-
db.exec("BEGIN");
|
|
89
|
-
try {
|
|
90
|
-
const out = await fn(self); // same connection — sqlite is single-writer
|
|
91
|
-
db.exec("COMMIT");
|
|
92
|
-
return out;
|
|
93
|
-
} catch (e) {
|
|
94
|
-
db.exec("ROLLBACK");
|
|
95
|
-
throw e;
|
|
96
|
-
}
|
|
97
|
-
},
|
|
98
|
-
async close() {
|
|
99
|
-
db.close();
|
|
100
|
-
},
|
|
101
|
-
};
|
|
102
|
-
return self;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
57
|
// --- Bun host ---------------------------------------------------------------
|
|
106
58
|
|
|
107
59
|
function bunHost(): JuneHost {
|
|
@@ -128,14 +80,7 @@ function bunHost(): JuneHost {
|
|
|
128
80
|
exited: child.exited,
|
|
129
81
|
};
|
|
130
82
|
},
|
|
131
|
-
|
|
132
|
-
// Non-literal specifier: only Bun has bun:sqlite, so resolve it at runtime.
|
|
133
|
-
const specifier = "bun:sqlite";
|
|
134
|
-
const { Database } = (await import(specifier)) as {
|
|
135
|
-
Database: new (p: string, o?: { create?: boolean }) => SyncSqlite;
|
|
136
|
-
};
|
|
137
|
-
return asyncSqlite(new Database(path, { create: true }));
|
|
138
|
-
},
|
|
83
|
+
openDb: openLocalSqlite, // bun:sqlite under the hood — see sqlite-driver.ts
|
|
139
84
|
};
|
|
140
85
|
}
|
|
141
86
|
|
|
@@ -212,23 +157,7 @@ function nodeHost(): JuneHost {
|
|
|
212
157
|
exited: new Promise((resolve) => child.on("exit", (code) => resolve(code ?? 0))),
|
|
213
158
|
};
|
|
214
159
|
},
|
|
215
|
-
|
|
216
|
-
const specifier = "node:sqlite"; // node-only builtin; resolve at runtime
|
|
217
|
-
const { DatabaseSync } = (await import(specifier)) as {
|
|
218
|
-
DatabaseSync: new (p: string) => {
|
|
219
|
-
prepare(sql: string): SyncStatement;
|
|
220
|
-
exec(sql: string): void;
|
|
221
|
-
close(): void;
|
|
222
|
-
};
|
|
223
|
-
};
|
|
224
|
-
const db = new DatabaseSync(path);
|
|
225
|
-
// Adapt node:sqlite (prepare()) to the query()-shaped SyncSqlite surface.
|
|
226
|
-
return asyncSqlite({
|
|
227
|
-
query: (sql) => db.prepare(sql),
|
|
228
|
-
exec: (sql) => db.exec(sql),
|
|
229
|
-
close: () => db.close(),
|
|
230
|
-
});
|
|
231
|
-
},
|
|
160
|
+
openDb: openLocalSqlite, // node:sqlite under the hood — see sqlite-driver.ts
|
|
232
161
|
};
|
|
233
162
|
}
|
|
234
163
|
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,38 @@
|
|
|
1
1
|
// @junejs/server — June's host adapters + dev server. Host-coupled (`node:*`
|
|
2
2
|
// allowed); it composes ON TOP of the pure `@junejs/core` contract layer.
|
|
3
3
|
|
|
4
|
+
export {
|
|
5
|
+
workers,
|
|
6
|
+
vercel,
|
|
7
|
+
type JuneAdapter,
|
|
8
|
+
type AdapterCapabilities,
|
|
9
|
+
type ResourcePlan,
|
|
10
|
+
type AdapterEntry,
|
|
11
|
+
type AdapterEmitContext,
|
|
12
|
+
} from "./adapter";
|
|
4
13
|
export { host, type JuneHost, type JuneDb, type RunResult, type ServeHandle, type SpawnedModule } from "./host";
|
|
5
14
|
export { sqlite, d1, type D1Database } from "./db";
|
|
6
15
|
export { memoryKv, redisKv } from "./kv";
|
|
7
16
|
export { localBlob, r2, type R2Bucket } from "./blob";
|
|
8
|
-
export {
|
|
17
|
+
export {
|
|
18
|
+
memoizeResources,
|
|
19
|
+
bindWorkerResources,
|
|
20
|
+
type WorkerEnv,
|
|
21
|
+
type ResourceFlags,
|
|
22
|
+
} from "./resources";
|
|
23
|
+
// Ambient data resources (db/kv/blob) live in their own package, `@junejs/db` —
|
|
24
|
+
// `import { db } from "@junejs/db"`. The host opens the resources and runs each
|
|
25
|
+
// request in the scope they read (pipeline imports runInScope from @junejs/db).
|
|
9
26
|
export { loadJuneConfig } from "./config-loader";
|
|
27
|
+
export {
|
|
28
|
+
migrate,
|
|
29
|
+
migrateApp,
|
|
30
|
+
classify,
|
|
31
|
+
readMigrations,
|
|
32
|
+
blockedMessage,
|
|
33
|
+
type Migration,
|
|
34
|
+
type MigrateResult,
|
|
35
|
+
} from "./migrate";
|
|
10
36
|
export { collection, entry, type ContentEntry } from "./content";
|
|
11
37
|
export {
|
|
12
38
|
listRoutes,
|
|
@@ -39,4 +65,5 @@ export {
|
|
|
39
65
|
type BuildResult,
|
|
40
66
|
} from "./build";
|
|
41
67
|
export { juneDeploy, type DeployResult } from "./deploy";
|
|
68
|
+
export { migrateD1, wranglerD1, resolveD1Database, type D1Exec } from "./d1-migrate";
|
|
42
69
|
export { startDevServer, type DevServer, type DevServerOptions } from "./dev";
|
package/src/kv.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `kv` resource adapters. The kv resource IS the cache (docs/data-layer-boundary
|
|
2
2
|
// .md: "reframe cache as the kv resource — it already is one"): these wrap
|
|
3
3
|
// @junejs/core/cache's CacheStore (memory / redis) as the simpler JuneKv contract,
|
|
4
|
-
// so there is ONE key-value system, surfaced both as cache() and as
|
|
4
|
+
// so there is ONE key-value system, surfaced both as cache() and as ambient kv.
|
|
5
5
|
|
|
6
6
|
import { memory, redis, type CacheStore } from "@junejs/core/cache";
|
|
7
7
|
import type { JuneKv, KvFactory } from "@junejs/core/resources";
|
package/src/migrate.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Migrations — the explicit, versioned schema story (Rails-shaped, adapted).
|
|
2
|
+
//
|
|
3
|
+
// `db/migrations/NNNN_name.sql` are applied IN ORDER; a `_june_migrations` ledger
|
|
4
|
+
// tracks which ran, so applying is idempotent. The deliberate design (see the
|
|
5
|
+
// data-layer discussion): never CREATE tables just because a db is connected —
|
|
6
|
+
// schema is an explicit, developer-owned artifact. Dev auto-applies the SAFE
|
|
7
|
+
// (additive) migrations; a DESTRUCTIVE one (data loss) STOPS and asks for
|
|
8
|
+
// explicit consent rather than silently dropping data. Production never
|
|
9
|
+
// auto-migrates: `june db migrate` is the explicit step.
|
|
10
|
+
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import type { JuneDb } from "@junejs/core/resources";
|
|
16
|
+
import type { JuneConfig } from "@junejs/core/config";
|
|
17
|
+
|
|
18
|
+
export type Migration = { id: string; sql: string };
|
|
19
|
+
export type ClassifiedMigration = Migration & { destructive: boolean; reasons: string[] };
|
|
20
|
+
export type MigrateResult = {
|
|
21
|
+
applied: string[]; // ids applied this run, in order
|
|
22
|
+
blocked: ClassifiedMigration | null; // the destructive migration that halted, if any
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Static DDL danger classifier. We flag DATA-LOSS verbs; additive DDL
|
|
26
|
+
// (CREATE / ADD COLUMN / INSERT / CREATE INDEX) and pragmas/transactions are
|
|
27
|
+
// safe. Conservative on RECALL — any destructive verb present gates the whole
|
|
28
|
+
// migration: a false positive costs one confirmation, a false negative costs
|
|
29
|
+
// data, so we err toward gating.
|
|
30
|
+
const DESTRUCTIVE: Array<{ re: RegExp; label: string }> = [
|
|
31
|
+
{ re: /\bdrop\s+table\b/i, label: "DROP TABLE" },
|
|
32
|
+
{ re: /\bdrop\s+index\b/i, label: "DROP INDEX" },
|
|
33
|
+
{ re: /\bdrop\s+view\b/i, label: "DROP VIEW" },
|
|
34
|
+
{ re: /\bdrop\s+trigger\b/i, label: "DROP TRIGGER" },
|
|
35
|
+
{ re: /\bdelete\s+from\b/i, label: "DELETE" },
|
|
36
|
+
{ re: /\btruncate\b/i, label: "TRUNCATE" },
|
|
37
|
+
// SQLite rewrites for column drop/rename go through ALTER TABLE … DROP/RENAME.
|
|
38
|
+
{ re: /\balter\s+table\b[\s\S]*?\bdrop\b/i, label: "ALTER TABLE … DROP" },
|
|
39
|
+
{ re: /\balter\s+table\b[\s\S]*?\brename\b/i, label: "ALTER TABLE … RENAME" },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function stripComments(sql: string): string {
|
|
43
|
+
return sql.replace(/--[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function classify(sql: string): { destructive: boolean; reasons: string[] } {
|
|
47
|
+
const clean = stripComments(sql);
|
|
48
|
+
const reasons = DESTRUCTIVE.filter((d) => d.re.test(clean)).map((d) => d.label);
|
|
49
|
+
return { destructive: reasons.length > 0, reasons };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Read db/migrations/*.sql in lexical order — zero-padded numeric prefixes
|
|
53
|
+
// (0001_, 0002_) sort correctly and double as the version id.
|
|
54
|
+
export async function readMigrations(dir: string): Promise<Migration[]> {
|
|
55
|
+
if (!existsSync(dir)) return [];
|
|
56
|
+
const files = (await readdir(dir)).filter((f) => f.endsWith(".sql")).sort();
|
|
57
|
+
return Promise.all(files.map(async (id) => ({ id, sql: await readFile(join(dir, id), "utf8") })));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function ensureLedger(db: JuneDb): Promise<void> {
|
|
61
|
+
await db.exec(
|
|
62
|
+
`create table if not exists _june_migrations (id text primary key, applied_at text not null)`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function appliedIds(db: JuneDb): Promise<Set<string>> {
|
|
67
|
+
await ensureLedger(db);
|
|
68
|
+
const rows = await db.query<{ id: string }>("select id from _june_migrations");
|
|
69
|
+
return new Set(rows.map((r) => r.id));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Apply pending migrations in order. Stops at the first DESTRUCTIVE one unless
|
|
73
|
+
// allowDestructive — migrations are ordered (a later one may depend on it), so
|
|
74
|
+
// the safe prefix is applied and the destructive one is returned for the caller
|
|
75
|
+
// to surface (dev warns; `june db migrate --allow-destructive` proceeds).
|
|
76
|
+
export async function migrate(
|
|
77
|
+
db: JuneDb,
|
|
78
|
+
dir: string,
|
|
79
|
+
opts: { allowDestructive?: boolean; now?: string } = {},
|
|
80
|
+
): Promise<MigrateResult> {
|
|
81
|
+
const already = await appliedIds(db);
|
|
82
|
+
const pending = (await readMigrations(dir)).filter((m) => !already.has(m.id));
|
|
83
|
+
const stamp = opts.now ?? new Date().toISOString();
|
|
84
|
+
const applied: string[] = [];
|
|
85
|
+
for (const m of pending) {
|
|
86
|
+
const c = classify(m.sql);
|
|
87
|
+
if (c.destructive && !opts.allowDestructive) {
|
|
88
|
+
return { applied, blocked: { ...m, ...c } };
|
|
89
|
+
}
|
|
90
|
+
await db.exec(m.sql);
|
|
91
|
+
await db.run("insert into _june_migrations (id, applied_at) values (?, ?)", [m.id, stamp]);
|
|
92
|
+
applied.push(m.id);
|
|
93
|
+
}
|
|
94
|
+
return { applied, blocked: null };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Open the app's declared db, apply migrations from <root>/db/migrations, close.
|
|
98
|
+
// Returns null when no db resource is declared (nothing to migrate). The CLI and
|
|
99
|
+
// dev startup both call this; they own the logging/exit semantics.
|
|
100
|
+
export async function migrateApp(
|
|
101
|
+
root: string,
|
|
102
|
+
config: JuneConfig,
|
|
103
|
+
opts: { allowDestructive?: boolean } = {},
|
|
104
|
+
): Promise<MigrateResult | null> {
|
|
105
|
+
const factory = config.resources?.db;
|
|
106
|
+
if (!factory) return null;
|
|
107
|
+
const db = await factory.open();
|
|
108
|
+
try {
|
|
109
|
+
return await migrate(db, join(root, "db", "migrations"), opts);
|
|
110
|
+
} finally {
|
|
111
|
+
await db.close();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The "this migration was gated" guidance, shared by dev (warns, keeps serving)
|
|
116
|
+
// and `june db migrate` (exits non-zero).
|
|
117
|
+
export function blockedMessage(m: ClassifiedMigration): string {
|
|
118
|
+
return (
|
|
119
|
+
`migration ${m.id} is destructive (${m.reasons.join(", ")}) and was NOT applied.\n` +
|
|
120
|
+
` review it, then apply explicitly: june db migrate --allow-destructive`
|
|
121
|
+
);
|
|
122
|
+
}
|