@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.
@@ -0,0 +1,185 @@
1
+ // CSS Modules — `import styles from "./X.module.css"` gives a map of local class
2
+ // names → scoped names, and the scoped CSS is collected into one stylesheet.
3
+ //
4
+ // The hard constraint: the scoped names must be byte-identical in dev SSR
5
+ // (un-bundled import), the worker build, and the client build (hydration must
6
+ // match SSR). We guarantee that by NEVER deriving the name from a bundler
7
+ // internal — `scopedName(stableKey, class)` is a pure hash of an app-relative
8
+ // path + the class. One main-thread pass globs + transforms every .module.css
9
+ // into a maps dict; the Bun plugin / Node loader / rolldown plugin are then dumb
10
+ // lookups into it, and the served/emitted CSS comes from the SAME pass — so the
11
+ // JS maps and the CSS agree by construction.
12
+ //
13
+ // Transforms go through postcss-modules, so the full CSS-Modules surface works:
14
+ // local scoping (the default), `:global(...)`, and cross-file `composes`. The one
15
+ // thing we override is generateScopedName — it's our deterministic hash, not a
16
+ // bundler internal, which is what makes dev/build/client/Node agree.
17
+
18
+ import { createHash } from "node:crypto";
19
+ import { readFile, readdir } from "node:fs/promises";
20
+ import { join, relative, sep } from "node:path";
21
+ import type { AcceptedPlugin, ChildNode } from "postcss";
22
+
23
+ export const MODULE_STYLES_URL = "/_june/modules.css";
24
+
25
+ export const isModuleCss = (id: string) => id.endsWith(".module.css");
26
+
27
+ // App-relative POSIX path — the stable scoping key (machine/cwd independent).
28
+ export function stableKey(appRoot: string, file: string): string {
29
+ return relative(appRoot, file).split(sep).join("/");
30
+ }
31
+
32
+ function scopedName(key: string, cls: string): string {
33
+ const h = createHash("sha256").update(`${key}|${cls}`).digest("hex").slice(0, 8);
34
+ return `${cls}_${h}`;
35
+ }
36
+
37
+ // Transform one .module.css via postcss-modules — correct scoping, `:global`,
38
+ // `composes` (cross-file), and url()/string safety — but with OUR deterministic
39
+ // generateScopedName, so dev SSR / worker build / client build / Node all produce
40
+ // identical names. postcss is imported lazily and only runs at build/dev time, so
41
+ // it never enters the worker graph.
42
+ export async function transformCssModule(
43
+ file: string,
44
+ appRoot: string,
45
+ ): Promise<{ map: Record<string, string>; css: string }> {
46
+ const postcss = (await import("postcss")).default;
47
+ const postcssModules = (await import("postcss-modules")).default as (opts: {
48
+ generateScopedName(name: string, filename: string): string;
49
+ getJSON(file: string, json: Record<string, string>): void;
50
+ }) => AcceptedPlugin;
51
+ let map: Record<string, string> = {};
52
+ const result = await postcss([
53
+ postcssModules({
54
+ generateScopedName: (name, filename) => scopedName(stableKey(appRoot, filename), name),
55
+ getJSON: (_f, json) => {
56
+ map = json;
57
+ },
58
+ }),
59
+ ]).process(await readFile(file, "utf8"), { from: file });
60
+ return { map, css: result.css };
61
+ }
62
+
63
+ // The JS a .module.css import resolves to.
64
+ export const moduleJs = (map: Record<string, string>): string =>
65
+ `export default ${JSON.stringify(map)};`;
66
+
67
+ export type ModuleMaps = Record<string, Record<string, string>>; // absPath → class map
68
+
69
+ async function findModuleCss(dir: string): Promise<string[]> {
70
+ const out: string[] = [];
71
+ const walk = async (d: string) => {
72
+ let entries;
73
+ try {
74
+ entries = await readdir(d, { withFileTypes: true });
75
+ } catch {
76
+ return;
77
+ }
78
+ for (const e of entries) {
79
+ const full = join(d, e.name);
80
+ if (e.isDirectory()) await walk(full);
81
+ else if (e.name.endsWith(".module.css")) out.push(full);
82
+ }
83
+ };
84
+ await walk(dir);
85
+ return out.sort();
86
+ }
87
+
88
+ // Drop byte-identical top-level rules. postcss-modules has no module graph, so
89
+ // `composes: x from "./y"` INLINES y's rule into this file's output — and we also
90
+ // emit y directly, so a composed rule lands N+1 times. Vite never duplicates
91
+ // (composes is a graph edge there); we get the same one-copy result by deduping.
92
+ //
93
+ // Two safety points:
94
+ // - Exact-string match only. Our scoped names are deterministic, so the copies
95
+ // are byte-identical; we never collapse two genuinely different rules.
96
+ // - Keep the LAST occurrence, not the first. With :global, two files can write
97
+ // the same raw selector with different bodies, so the cascade can interleave
98
+ // (red, blue, red). The winner is the last declaration; dropping the EARLIER
99
+ // identical copies leaves the last one in place, so the computed result is
100
+ // unchanged. Keeping the first instead would change which rule wins.
101
+ async function dedupeRules(css: string): Promise<string> {
102
+ const postcss = (await import("postcss")).default;
103
+ const root = postcss.parse(css);
104
+ const last = new Map<string, ChildNode>();
105
+ root.each((node) => {
106
+ if (node.type === "rule" || node.type === "atrule") last.set(node.toString(), node);
107
+ });
108
+ root.each((node) => {
109
+ if ((node.type === "rule" || node.type === "atrule") && last.get(node.toString()) !== node) {
110
+ node.remove();
111
+ }
112
+ });
113
+ return root.toString();
114
+ }
115
+
116
+ // One pass over app/**/*.module.css → the per-file class maps AND the combined
117
+ // scoped stylesheet. null css when there are none. Deterministic, so the maps the
118
+ // loaders hand out match the CSS that's served/emitted.
119
+ export async function buildModuleCss(
120
+ appDir: string,
121
+ appRoot: string,
122
+ ): Promise<{ maps: ModuleMaps; css: string | null }> {
123
+ const files = await findModuleCss(appDir);
124
+ const maps: ModuleMaps = {};
125
+ const parts: string[] = [];
126
+ for (const file of files) {
127
+ const { map, css } = await transformCssModule(file, appRoot);
128
+ maps[file] = map;
129
+ parts.push(css);
130
+ }
131
+ return { maps, css: parts.length ? await dedupeRules(parts.join("\n")) : null };
132
+ }
133
+
134
+ // --- the three dumb interception points (all lookups into `maps`) ------------
135
+
136
+ // BUILD: a rolldown plugin. rolldown removed CSS support and infers type from
137
+ // the .css extension, so `moduleType: "js"` forces it to treat our map as JS.
138
+ // Added to BOTH the worker bundle and the client bundle.
139
+ export function rolldownCssModulesPlugin(maps: ModuleMaps): {
140
+ name: string;
141
+ load(id: string): { code: string; moduleType: "js" } | null;
142
+ } {
143
+ return {
144
+ name: "june-css-modules",
145
+ load(id) {
146
+ if (!isModuleCss(id)) return null;
147
+ return { code: moduleJs(maps[id] ?? {}), moduleType: "js" };
148
+ },
149
+ };
150
+ }
151
+
152
+ // RUNTIME (in-process imports — dev SSR + build prerender): register an
153
+ // interceptor so `import "x.module.css"` returns the scoped map. The maps are a
154
+ // module-level reference the Bun plugin reads LIVE (one process can serve several
155
+ // apps across tests/build); the Node loader gets a snapshot at register time.
156
+ let activeMaps: ModuleMaps = {};
157
+ let runtimeRegistered = false;
158
+
159
+ function registerBunCssModules(): void {
160
+ const B = (globalThis as { Bun?: { plugin(p: unknown): void } }).Bun!;
161
+ B.plugin({
162
+ name: "june-css-modules",
163
+ setup(build: { onLoad(opts: { filter: RegExp }, cb: (a: { path: string }) => unknown): void }) {
164
+ build.onLoad({ filter: /\.module\.css$/ }, (args) => ({
165
+ loader: "js",
166
+ contents: moduleJs(activeMaps[args.path] ?? {}),
167
+ }));
168
+ },
169
+ });
170
+ }
171
+
172
+ async function registerNodeCssModules(maps: ModuleMaps): Promise<void> {
173
+ const { register } = await import("node:module");
174
+ register(new URL("./css-modules-loader.mjs", import.meta.url), { data: { maps } });
175
+ }
176
+
177
+ // Point the runtime interceptor at THIS app's maps (and register it once, the
178
+ // first time there's anything to intercept). No-op until an app has module CSS.
179
+ export async function registerCssModules(maps: ModuleMaps): Promise<void> {
180
+ activeMaps = maps;
181
+ if (runtimeRegistered || !Object.keys(maps).length) return;
182
+ runtimeRegistered = true;
183
+ if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") registerBunCssModules();
184
+ else await registerNodeCssModules(maps);
185
+ }
package/src/css.ts ADDED
@@ -0,0 +1,118 @@
1
+ // CSS — the human surface's styling. Convention over import: if `app/global.css`
2
+ // exists, June auto-links it (no `import "./global.css"`). Dev serves it at the
3
+ // stable /_june/global.css; build content-hashes it to /_june/global.<hash>.css
4
+ // (served immutable). Only the asset HREF diverges dev↔built, never the rendered
5
+ // structure — so dev/built parity (the agent surface) holds.
6
+ //
7
+ // Floor: plain CSS, zero deps. Blessed: if global.css opts into Tailwind
8
+ // (`@import "tailwindcss"`), June compiles it via the app's own Tailwind v4
9
+ // (resolved from the app, not bundled into June) — Tailwind auto-detects the
10
+ // content it scans. CSS never touches the agent projections (.md/.json/mcp).
11
+
12
+ import { existsSync } from "node:fs";
13
+ import { readFile } from "node:fs/promises";
14
+ import { createRequire } from "node:module";
15
+ import { join } from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+
18
+ // Framework-emitted assets live under the reserved /_june/ prefix so they never
19
+ // collide with — or squat on — a user's own paths (cf. Next /_next/, Astro
20
+ // /_astro/). Build content-hashes the file under here; this is the dev URL.
21
+ export const STYLES_URL = "/_june/global.css";
22
+ const GLOBAL_CSS = "global.css";
23
+
24
+ // app/global.css, or null when the app declares no stylesheet.
25
+ export function findGlobalCss(appDir: string): string | null {
26
+ const p = join(appDir, GLOBAL_CSS);
27
+ return existsSync(p) ? p : null;
28
+ }
29
+
30
+ const usesTailwind = (css: string) => /@import\s+["']tailwindcss["']|@tailwind\b/.test(css);
31
+
32
+ // Resolve a tool from the APP's node_modules (Tailwind/postcss are the app's
33
+ // deps, not June's). Returns null when absent.
34
+ function resolveFromApp(specifier: string, appDir: string): string | null {
35
+ try {
36
+ return createRequire(pathToFileURL(join(appDir, "package.json"))).resolve(specifier);
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ async function compileTailwind(
43
+ css: string,
44
+ file: string,
45
+ appDir: string,
46
+ minify: boolean,
47
+ ): Promise<string | null> {
48
+ const postcssPath = resolveFromApp("postcss", appDir);
49
+ const tailwindPath = resolveFromApp("@tailwindcss/postcss", appDir);
50
+ if (!postcssPath || !tailwindPath) return null;
51
+ const postcss = ((await import(postcssPath)) as { default: (p: unknown[]) => { process(css: string, opts: { from: string }): Promise<{ css: string }> } }).default;
52
+ const tailwind = ((await import(tailwindPath)) as { default: (opts?: unknown) => unknown }).default;
53
+ // Tailwind v4 only emits the classes the project actually uses (content
54
+ // detection = tree-shaking). For build we ALSO minify via its Lightning CSS
55
+ // optimizer; dev stays unminified for readability.
56
+ const plugin = minify ? tailwind({ optimize: { minify: true } }) : tailwind();
57
+ const result = await postcss([plugin]).process(css, { from: file });
58
+ return result.css;
59
+ }
60
+
61
+ // Dev-only memo. The dev server re-fetches /global.css on EVERY navigation, but
62
+ // the CSS only changes on edit — so compiling each time wastes ~18ms a nav (and
63
+ // ~145ms the first time, Tailwind's design-system build). Cache it; the .css
64
+ // watcher calls invalidateCss() on edit, and a .tsx class change restarts the
65
+ // whole dev process (cache starts empty there), so the cache never goes stale.
66
+ let devCache: { appDir: string; css: string | null } | null = null;
67
+
68
+ export async function processCssCached(appDir: string): Promise<string | null> {
69
+ if (devCache?.appDir === appDir) return devCache.css;
70
+ const css = await processCss(appDir);
71
+ devCache = { appDir, css };
72
+ return css;
73
+ }
74
+
75
+ export function invalidateCss(): void {
76
+ devCache = null;
77
+ }
78
+
79
+ // Minify a finished stylesheet with Lightning CSS — the same engine Tailwind v4
80
+ // optimizes with, so all of June's build-time CSS comes out consistently. It only
81
+ // touches whitespace/comments/redundancy, never identifiers, so scoped CSS-module
82
+ // class names survive intact (hydration stays byte-identical to SSR). Lazy import:
83
+ // build/dev-time only, never in the worker graph. Falls back to the input if
84
+ // Lightning CSS can't parse it, so minification can never break a build.
85
+ export async function minifyCss(css: string, filename = "input.css"): Promise<string> {
86
+ try {
87
+ const { transform } = await import("lightningcss");
88
+ const { code } = transform({ filename, code: Buffer.from(css), minify: true });
89
+ return code.toString();
90
+ } catch (e) {
91
+ console.warn("[june] CSS minify failed; emitting unminified:", e);
92
+ return css;
93
+ }
94
+ }
95
+
96
+ // Read + process app/global.css → the CSS to serve/emit. null when absent.
97
+ // build passes { minify: true } (optimized, deployable); dev leaves it readable.
98
+ export async function processCss(
99
+ appDir: string,
100
+ opts: { minify?: boolean } = {},
101
+ ): Promise<string | null> {
102
+ const file = findGlobalCss(appDir);
103
+ if (!file) return null;
104
+ const css = await readFile(file, "utf8");
105
+ if (usesTailwind(css)) {
106
+ const compiled = await compileTailwind(css, file, appDir, !!opts.minify).catch((e) => {
107
+ console.warn("[june] Tailwind compile failed; serving raw CSS:", e);
108
+ return null;
109
+ });
110
+ if (compiled !== null) return compiled; // Tailwind already minified when asked
111
+ console.warn(
112
+ "[june] global.css uses Tailwind but @tailwindcss/postcss isn't installed — serving raw CSS.",
113
+ );
114
+ }
115
+ // Plain CSS (or the Tailwind fallback): minify here so the non-Tailwind path
116
+ // is optimized too, matching the Tailwind path.
117
+ return opts.minify ? await minifyCss(css, file) : css;
118
+ }
@@ -0,0 +1,209 @@
1
+ // Apply June migrations to a REMOTE D1 at deploy time.
2
+ //
3
+ // The dev `db` opens locally (sqlite().open()); D1's binding only exists inside
4
+ // workerd at request time, so the deploy host can't `factory.open()` it. Instead
5
+ // we reach D1 out-of-band through `wrangler d1 execute --remote`, wrapped as a
6
+ // JuneDb — so the SAME migrate() (same ledger, same destructive classifier the
7
+ // dev path uses) runs against production unchanged. dev and prod converge on one
8
+ // ordered ledger; the only difference is the transport.
9
+ //
10
+ // HOST-ONLY: spawns wrangler + writes temp files (node:*). Imported by deploy.ts,
11
+ // which is never in the worker graph — keep it that way.
12
+
13
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
14
+ import { tmpdir } from "node:os";
15
+ import { basename, join } from "node:path";
16
+
17
+ import type { JuneDb, RunResult } from "@junejs/core/resources";
18
+
19
+ import { migrate, type MigrateResult } from "./migrate";
20
+
21
+ // One `wrangler d1 execute` invocation. `mode:"command"` passes SQL inline (with
22
+ // --json so we can read results/meta); `mode:"file"` writes a temp .sql and uses
23
+ // --file (right for DDL + multi-statement migration bodies). Injectable so tests
24
+ // drive the adapter without spawning wrangler or touching the network.
25
+ export type D1Exec = (req: {
26
+ sql: string;
27
+ mode: "command" | "file";
28
+ json: boolean;
29
+ }) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
30
+
31
+ // `?`-placeholder binding isn't available over `d1 execute --command`, so inline
32
+ // the params ourselves. Only used for the framework's own ledger ops (controlled
33
+ // values), but escape properly regardless.
34
+ export function inlineParams(sql: string, params: readonly unknown[]): string {
35
+ let i = 0;
36
+ return sql.replace(/\?/g, () => {
37
+ const p = params[i++];
38
+ if (p === null || p === undefined) return "null";
39
+ if (typeof p === "number" || typeof p === "bigint") return String(p);
40
+ if (typeof p === "boolean") return p ? "1" : "0";
41
+ return `'${String(p).replace(/'/g, "''")}'`;
42
+ });
43
+ }
44
+
45
+ // wrangler --json prints a `[{ results, success, meta }]` array on stdout (one
46
+ // element per statement; we read the first, since our --command calls are always
47
+ // single-statement ledger ops). The clean case is pure JSON, but wrangler can
48
+ // emit a banner/notice first — so fall back to parsing from the last line that
49
+ // opens an array (the JSON is the last top-level value).
50
+ function parseD1Json(stdout: string): { results: unknown[]; meta: Record<string, number> } {
51
+ type Row = { results?: unknown[]; meta?: Record<string, number> };
52
+ const pick = (arr: Row[]) => {
53
+ const f = arr[0] ?? {};
54
+ return { results: f.results ?? [], meta: f.meta ?? {} };
55
+ };
56
+ const trimmed = stdout.trim();
57
+ try {
58
+ return pick(JSON.parse(trimmed) as Row[]); // common case: pure JSON
59
+ } catch {
60
+ const lines = trimmed.split("\n");
61
+ for (let i = lines.length - 1; i >= 0; i--) {
62
+ if (lines[i]!.trimStart().startsWith("[")) {
63
+ try {
64
+ return pick(JSON.parse(lines.slice(i).join("\n")) as Row[]);
65
+ } catch {
66
+ /* a nested array opener — keep scanning upward for the real one */
67
+ }
68
+ }
69
+ }
70
+ throw new Error(`wrangler d1 execute: no JSON array in output:\n${stdout}`);
71
+ }
72
+ }
73
+
74
+ // The default transport: build the real argv and spawn wrangler. database is the
75
+ // D1 `database_name`; configPath pins which wrangler config (and thus account)
76
+ // the binding resolves against.
77
+ function defaultExec(opts: {
78
+ database: string;
79
+ configPath: string;
80
+ cwd: string;
81
+ wranglerVersion: string;
82
+ }): D1Exec {
83
+ return async ({ sql, mode, json }) => {
84
+ const base = [
85
+ "bunx",
86
+ `wrangler@${opts.wranglerVersion}`,
87
+ "d1",
88
+ "execute",
89
+ opts.database,
90
+ "--remote",
91
+ "--config",
92
+ opts.configPath,
93
+ ];
94
+ let tmpDir: string | undefined;
95
+ if (mode === "file") {
96
+ tmpDir = await mkdtemp(join(tmpdir(), "june-d1-"));
97
+ const tmp = join(tmpDir, "migration.sql");
98
+ await writeFile(tmp, sql);
99
+ base.push("--file", tmp);
100
+ } else {
101
+ base.push("--command", sql);
102
+ }
103
+ if (json) base.push("--json");
104
+ try {
105
+ const proc = Bun.spawn(base, { cwd: opts.cwd, stdout: "pipe", stderr: "pipe" });
106
+ const [stdout, stderr, exitCode] = await Promise.all([
107
+ new Response(proc.stdout).text(),
108
+ new Response(proc.stderr).text(),
109
+ proc.exited,
110
+ ]);
111
+ return { stdout, stderr, exitCode };
112
+ } finally {
113
+ if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
114
+ }
115
+ };
116
+ }
117
+
118
+ // A JuneDb backed by `wrangler d1 execute`. query/get/run go through --command
119
+ // (+ --json); exec (DDL, migration bodies) goes through --file.
120
+ export function wranglerD1(opts: {
121
+ database: string;
122
+ configPath: string;
123
+ cwd: string;
124
+ wranglerVersion?: string;
125
+ exec?: D1Exec;
126
+ }): JuneDb {
127
+ const exec = opts.exec ?? defaultExec({ ...opts, wranglerVersion: opts.wranglerVersion ?? "4.99.0" });
128
+ const fail = (r: { stderr: string; stdout: string; exitCode: number }) => {
129
+ throw new Error(`wrangler d1 execute failed (exit ${r.exitCode})\n${r.stderr || r.stdout}`.trim());
130
+ };
131
+ const db: JuneDb = {
132
+ async query<T>(sql: string, params: unknown[] = []) {
133
+ const r = await exec({ sql: inlineParams(sql, params), mode: "command", json: true });
134
+ if (r.exitCode !== 0) fail(r);
135
+ return parseD1Json(r.stdout).results as T[];
136
+ },
137
+ async get<T>(sql: string, params: unknown[] = []) {
138
+ return ((await db.query<T>(sql, params))[0] ?? undefined) as T | undefined;
139
+ },
140
+ async run(sql: string, params: unknown[] = []): Promise<RunResult> {
141
+ const r = await exec({ sql: inlineParams(sql, params), mode: "command", json: true });
142
+ if (r.exitCode !== 0) fail(r);
143
+ const { meta } = parseD1Json(r.stdout);
144
+ return { changes: meta.changes ?? 0, lastInsertRowid: meta.last_row_id ?? 0 };
145
+ },
146
+ async exec(sql: string) {
147
+ const r = await exec({ sql, mode: "file", json: false });
148
+ if (r.exitCode !== 0) fail(r);
149
+ },
150
+ // D1 has no interactive transactions; run inline (same as the edge d1()
151
+ // adapter). Migrations are one-statement-script-per-file anyway.
152
+ async transaction<T>(fn: (tx: JuneDb) => Promise<T>) {
153
+ return fn(db);
154
+ },
155
+ async close() {
156
+ /* nothing to close — each call is its own wrangler invocation */
157
+ },
158
+ };
159
+ return db;
160
+ }
161
+
162
+ // Resolve which D1 database `june deploy` should migrate. The wrangler config that
163
+ // will be deployed is the source of truth; fall back to the name `june build`
164
+ // derives (`<app>-db`) when the config is TOML or has no d1 binding declared.
165
+ export async function resolveD1Database(appRoot: string, configPath: string): Promise<string | null> {
166
+ try {
167
+ const raw = await Bun.file(configPath).text();
168
+ if (/\.jsonc?$/.test(configPath)) {
169
+ const json = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
170
+ const cfg = JSON.parse(json) as { d1_databases?: Array<{ database_name?: string }> };
171
+ const name = cfg.d1_databases?.[0]?.database_name;
172
+ if (name) return name;
173
+ }
174
+ } catch {
175
+ /* fall through to the derived default */
176
+ }
177
+ const pkgPath = join(appRoot, "package.json");
178
+ let pkgName: string | undefined;
179
+ try {
180
+ pkgName = (JSON.parse(await Bun.file(pkgPath).text()) as { name?: string }).name;
181
+ } catch {
182
+ /* no package.json — use the directory name */
183
+ }
184
+ const defaultName = (pkgName ?? basename(appRoot)).replace(/[^a-z0-9-]/gi, "-").toLowerCase();
185
+ return `${defaultName}-db`;
186
+ }
187
+
188
+ // Open the remote D1 as a JuneDb and run the app's db/migrations against it —
189
+ // the same migrate() the dev path uses, so the destructive gate behaves
190
+ // identically (blocked → returned, not applied).
191
+ export async function migrateD1(opts: {
192
+ appRoot: string;
193
+ database: string;
194
+ configPath: string;
195
+ allowDestructive?: boolean;
196
+ exec?: D1Exec;
197
+ now?: string;
198
+ }): Promise<MigrateResult> {
199
+ const db = wranglerD1({
200
+ database: opts.database,
201
+ configPath: opts.configPath,
202
+ cwd: opts.appRoot,
203
+ exec: opts.exec,
204
+ });
205
+ return migrate(db, join(opts.appRoot, "db", "migrations"), {
206
+ allowDestructive: opts.allowDestructive,
207
+ now: opts.now,
208
+ });
209
+ }
package/src/d1.ts ADDED
@@ -0,0 +1,53 @@
1
+ // D1 (Cloudflare) `db` adapter — the EDGE half of the db resource. It adapts
2
+ // D1's prepare().bind().all()/first()/run() to the @junejs/core JuneDb contract.
3
+ //
4
+ // PURE / EDGE-SAFE: this module has NO host imports (no node:*, no ./host), so
5
+ // the generated worker can import it (via bindWorkerResources) without dragging
6
+ // the dev server into the workerd bundle. The local sqlite() adapter — which is
7
+ // host-coupled — lives in db.ts and stays out of the worker graph. D1 *is*
8
+ // SQLite, so the same SQL and Juno tables that run on the dev file run here.
9
+
10
+ import type { DbFactory, JuneDb, RunResult } from "@junejs/core/resources";
11
+
12
+ type D1Result<T> = { results: T[] };
13
+ type D1Meta = { changes?: number; last_row_id?: number };
14
+ interface D1PreparedStatement {
15
+ bind(...values: unknown[]): D1PreparedStatement;
16
+ all<T = unknown>(): Promise<D1Result<T>>;
17
+ first<T = unknown>(): Promise<T | null>;
18
+ run(): Promise<{ meta: D1Meta }>;
19
+ }
20
+ export interface D1Database {
21
+ prepare(sql: string): D1PreparedStatement;
22
+ exec(sql: string): Promise<unknown>;
23
+ }
24
+
25
+ // The binding comes from the worker's env at request time, so d1() takes the
26
+ // already-bound D1Database.
27
+ export function d1(binding: D1Database): DbFactory {
28
+ const db: JuneDb = {
29
+ async query<T>(sql: string, params: unknown[] = []) {
30
+ return (await binding.prepare(sql).bind(...params).all<T>()).results;
31
+ },
32
+ async get<T>(sql: string, params: unknown[] = []) {
33
+ return (await binding.prepare(sql).bind(...params).first<T>()) ?? undefined;
34
+ },
35
+ async run(sql: string, params: unknown[] = []): Promise<RunResult> {
36
+ const { meta } = await binding.prepare(sql).bind(...params).run();
37
+ return { changes: meta.changes ?? 0, lastInsertRowid: meta.last_row_id ?? 0 };
38
+ },
39
+ async exec(sql: string) {
40
+ await binding.exec(sql);
41
+ },
42
+ // D1 has no INTERACTIVE transactions (only batch()); run the fn inline. For
43
+ // true atomicity, group writes through batch() at the call site. Documented
44
+ // limitation of the edge backend — the contract stays uniform.
45
+ async transaction<T>(fn: (tx: JuneDb) => Promise<T>) {
46
+ return fn(db);
47
+ },
48
+ async close() {
49
+ /* the binding outlives the request; nothing to close */
50
+ },
51
+ };
52
+ return { kind: "d1", open: async () => db };
53
+ }
package/src/db.ts CHANGED
@@ -2,14 +2,21 @@
2
2
  // Declared in june.config.ts (`resources.db: sqlite(...)` / `d1(...)`), opened
3
3
  // by the host, injected onto RouteContext as `ctx.db`. The framework depends on
4
4
  // the JuneDb contract; these adapters (and Juno on top) are swappable.
5
+ //
6
+ // sqlite() is host-coupled (node:fs + ./host) and DEV-only. The edge-safe d1()
7
+ // adapter lives in ./d1 so the generated worker can import it without dragging
8
+ // this module's host imports into the workerd bundle; it is re-exported here so
9
+ // `@junejs/server/db` stays the one place both db adapters are named.
5
10
 
6
11
  import { mkdirSync } from "node:fs";
7
12
  import { dirname } from "node:path";
8
13
 
9
- import type { DbFactory, JuneDb, RunResult } from "@junejs/core/resources";
14
+ import type { DbFactory } from "@junejs/core/resources";
10
15
 
11
16
  import { host } from "./host";
12
17
 
18
+ export { d1, type D1Database } from "./d1";
19
+
13
20
  // Local SQLite — the zero-config dev default. A persistent FILE, not :memory:,
14
21
  // on purpose: `june dev` restarts the process on every save (the watch
15
22
  // supervisor), so an in-memory default would evaporate your dev data at each
@@ -26,49 +33,3 @@ export function sqlite(opts: { path?: string } = {}): DbFactory {
26
33
  },
27
34
  };
28
35
  }
29
-
30
- // --- D1 (Cloudflare) — the third openDb impl (rebuild-plan Phase 5) ----------
31
- // The binding comes from the worker's env at request time, so d1() takes the
32
- // already-bound D1Database. Adapts D1's prepare().bind().all()/first()/run() to
33
- // the JuneDb contract.
34
-
35
- type D1Result<T> = { results: T[] };
36
- type D1Meta = { changes?: number; last_row_id?: number };
37
- interface D1PreparedStatement {
38
- bind(...values: unknown[]): D1PreparedStatement;
39
- all<T = unknown>(): Promise<D1Result<T>>;
40
- first<T = unknown>(): Promise<T | null>;
41
- run(): Promise<{ meta: D1Meta }>;
42
- }
43
- export interface D1Database {
44
- prepare(sql: string): D1PreparedStatement;
45
- exec(sql: string): Promise<unknown>;
46
- }
47
-
48
- export function d1(binding: D1Database): DbFactory {
49
- const db: JuneDb = {
50
- async query<T>(sql: string, params: unknown[] = []) {
51
- return (await binding.prepare(sql).bind(...params).all<T>()).results;
52
- },
53
- async get<T>(sql: string, params: unknown[] = []) {
54
- return (await binding.prepare(sql).bind(...params).first<T>()) ?? undefined;
55
- },
56
- async run(sql: string, params: unknown[] = []): Promise<RunResult> {
57
- const { meta } = await binding.prepare(sql).bind(...params).run();
58
- return { changes: meta.changes ?? 0, lastInsertRowid: meta.last_row_id ?? 0 };
59
- },
60
- async exec(sql: string) {
61
- await binding.exec(sql);
62
- },
63
- // D1 has no INTERACTIVE transactions (only batch()); run the fn inline. For
64
- // true atomicity, group writes through batch() at the call site. Documented
65
- // limitation of the edge backend — the contract stays uniform.
66
- async transaction<T>(fn: (tx: JuneDb) => Promise<T>) {
67
- return fn(db);
68
- },
69
- async close() {
70
- /* the binding outlives the request; nothing to close */
71
- },
72
- };
73
- return { kind: "d1", open: async () => db };
74
- }