@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
|
@@ -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
|
|
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
|
-
}
|