@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/src/deploy.ts CHANGED
@@ -1,35 +1,114 @@
1
- // `june deploy` — ship a June app. Workers target (v1).
2
- //
3
- // The verb is fixed, the target is an adapter (same seam philosophy as
4
- // JuneHost): today this orchestrates `june build` + wrangler (which owns auth,
5
- // wasm/ttf rules, and the upload API); a future "june-cloud" target swaps the
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
- export async function juneDeploy(
21
- appRoot: string,
22
- options: { dryRun?: boolean; skipBuild?: boolean } = {},
23
- ): Promise<DeployResult> {
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
- const target = cfg.deploy?.target ?? "workers";
26
- if (target !== "workers") throw new Error(`unknown deploy target: ${target}`);
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 proc = Bun.spawn(args, { cwd: appRoot, stdout: "pipe", stderr: "pipe" });
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 → location.reload()
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
- // Dev HTML is fully buffered by the pipeline already (allReady), so a
82
- // text() round-trip costs nothing.
83
- const html = await res.text();
84
- const tag = `<script src="${SCRIPT_PATH}" defer></script>`;
85
- const injected = html.includes("</body>") ? html.replace("</body>", `${tag}</body>`) : html + tag;
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 just grew; let the host recompute
88
- return new Response(injected, { status: res.status, headers });
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
- async openDb(path) {
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
- async openDb(path) {
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 { memoizeResources } from "./resources";
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 ctx.kv.
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
+ }