@junejs/server 0.0.17 → 0.0.19

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "description": "June host adapters + dev server. Host-coupled; depends on the pure @junejs/core contract layer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,19 +30,18 @@
30
30
  "typecheck": "tsc --noEmit"
31
31
  },
32
32
  "dependencies": {
33
- "@junejs/core": "0.0.17",
34
- "@junejs/db": "0.0.17",
33
+ "@junejs/core": "0.0.19",
34
+ "@junejs/db": "0.0.19",
35
35
  "marked": "^18.0.5",
36
36
  "rolldown": "^1.1.0",
37
- "postcss": "^8.4.0",
38
- "postcss-modules": "^6.0.0",
39
37
  "lightningcss": "^1.27.0"
40
38
  },
41
39
  "peerDependencies": {
42
40
  "react": "^19.2.0",
43
41
  "react-dom": "^19.2.0",
44
42
  "pg": "^8.0.0",
45
- "mysql2": "^3.0.0"
43
+ "mysql2": "^3.0.0",
44
+ "@libsql/client": "^0.14.0 || ^0.15.0 || ^0.16.0 || ^0.17.0"
46
45
  },
47
46
  "peerDependenciesMeta": {
48
47
  "pg": {
@@ -50,6 +49,9 @@
50
49
  },
51
50
  "mysql2": {
52
51
  "optional": true
52
+ },
53
+ "@libsql/client": {
54
+ "optional": true
53
55
  }
54
56
  },
55
57
  "repository": {
@@ -57,6 +59,7 @@
57
59
  "url": "https://github.com/junebuild/june"
58
60
  },
59
61
  "devDependencies": {
62
+ "@libsql/client": "^0.17.3",
60
63
  "@types/pg": "^8.20.0",
61
64
  "mysql2": "^3.22.5",
62
65
  "pg": "^8.21.0"
package/src/adapter.ts CHANGED
@@ -143,14 +143,14 @@ export function workers(opts?: { name?: string; domain?: string }): JuneAdapter
143
143
  // deprecated the Edge Functions *product* and officially recommends the Node
144
144
  // runtime for performance + reliability, and Fluid compute (in-function
145
145
  // concurrency) closed the cold-start gap edge used to win on. Node also lifts
146
- // edge's limits (no 1–4MB code cap, full API) and is the substrate a future
147
- // HTTP/TCP db backend (Neon/Turso) needs. `vercel({ runtime: "edge" })` still
146
+ // edge's limits (no 1–4MB code cap, full API). `vercel({ runtime: "edge" })` still
148
147
  // emits an Edge Function for latency-critical global cases — the runtime is not
149
148
  // removed, just discouraged. BOTH run the SAME bundle; only the .func packaging
150
149
  // (.vc-config.json + the export shape) differs.
151
150
  //
152
- // No db yet (D1 is Cloudflare-only) — declaring one still fails fast; the Node
153
- // runtime makes a real backend possible but it isn't wired here.
151
+ // db: turso() works (libsql over HTTPS — the build bundles the web client, the
152
+ // request opens it from env → ambient `import { db }`). sqlite()/d1() are
153
+ // Cloudflare-only (a D1 binding); validate() rejects them with that guidance.
154
154
  //
155
155
  // Routing: only the hashed framework assets (/_june/*) are served statically by
156
156
  // the CDN (their exact-path URLs match June's scheme); every page + projection
@@ -176,12 +176,15 @@ export function vercel(opts?: { runtime?: "node" | "edge"; regions?: string[] })
176
176
  // and runs on workerd AND Node alike. Mirrors workers()'s shape.
177
177
  conditions: ["edge-light", "edge", "import", "default"],
178
178
 
179
- validate({ plan }) {
180
- if (plan.db) {
179
+ validate({ config }) {
180
+ // turso() is libsql over HTTPS — it runs on Vercel (the build bundles the web
181
+ // client; the request opens it from env → ambient `import { db }`). sqlite()/
182
+ // d1() need a D1 binding, which is Cloudflare-only.
183
+ const kind = config.resources?.db?.kind;
184
+ if (kind && kind !== "turso") {
181
185
  throw new Error(
182
- "vercel(): the Vercel adapter has no db backend yet (D1 is Cloudflare-only).\n" +
183
- " Remove the `db` resource from june.config.ts, or deploy to Workers.\n" +
184
- " An HTTP-driver db (Neon/Turso) for Vercel is planned.",
186
+ `vercel(): the '${kind}' db isn't supported on Vercel (D1 is Cloudflare-only).\n` +
187
+ " Use turso() (libsql over HTTPS) for a Vercel db, or deploy to Workers.",
185
188
  );
186
189
  }
187
190
  },
package/src/build.ts CHANGED
@@ -378,12 +378,25 @@ export async function juneBuild(
378
378
  kv: !!resourcesCfg?.kv,
379
379
  blob: !!resourcesCfg?.blob,
380
380
  };
381
- if (hasResources) {
381
+ // Two SQLite-dialect defaults, picked by the declared db's kind:
382
+ // turso() → libsql over HTTPS, connected from env (TURSO_*) via the
383
+ // bundled web client. Open the declared factory directly so it
384
+ // feeds the ambient `import { db }` scope (re-emitted by kind,
385
+ // not imported from the app config, to avoid the host barrel).
386
+ // sqlite()/d1() → a D1 binding from env (env.DB), via bindWorkerResources.
387
+ // (kv/blob on a turso deploy aren't wired yet — db-only.)
388
+ const tursoDb = resourcesCfg?.db?.kind === "turso";
389
+ if (tursoDb) {
390
+ imports.push(`import { turso } from "@junejs/server/db";`);
391
+ imports.push(`import { memoizeResources } from "@junejs/server/resources";`);
392
+ } else if (hasResources) {
382
393
  imports.push(`import { bindWorkerResources } from "@junejs/server/resources";`);
383
394
  }
384
- const resourcesField = hasResources
385
- ? `\n resources: bindWorkerResources(${JSON.stringify(resourceFlags)}),`
386
- : "";
395
+ const resourcesField = tursoDb
396
+ ? `\n resources: memoizeResources({ db: turso() }),`
397
+ : hasResources
398
+ ? `\n resources: bindWorkerResources(${JSON.stringify(resourceFlags)}),`
399
+ : "";
387
400
 
388
401
  // Opt-in Tier-3 data layer: import its installDataLayer from the declared module
389
402
  // and call it at worker boot — the prod twin of the dev host's dataLayer.install()
@@ -521,10 +534,12 @@ ${adapterEntry.wrap("pipeline")}
521
534
  ? (JSON.parse(await Bun.file(pkgPath).text()) as { name?: string }).name
522
535
  : undefined;
523
536
  const defaultName = (pkgName ?? basename(appRoot)).replace(/[^a-z0-9-]/gi, "-").toLowerCase();
524
- // A declared `db` → a D1 binding named DB (the name bindWorkerResources reads).
525
- const plan: ResourcePlan = resourcesCfg?.db
526
- ? { db: { binding: "DB", databaseName: `${defaultName}-db` } }
527
- : {};
537
+ // A declared D1-backed db (sqlite/d1) → a D1 binding named DB (the name
538
+ // bindWorkerResources reads). turso() connects from env, not a binding — no plan.
539
+ const plan: ResourcePlan =
540
+ resourcesCfg?.db && resourcesCfg.db.kind !== "turso"
541
+ ? { db: { binding: "DB", databaseName: `${defaultName}-db` } }
542
+ : {};
528
543
  await adapter.emit({ appRoot, outDir, hasAssets, linkHeader, config: fullConfig, plan, defaultName });
529
544
  if (plan.db) {
530
545
  console.log(
@@ -3,22 +3,21 @@
3
3
  //
4
4
  // The hard constraint: the scoped names must be byte-identical in dev SSR
5
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.
6
+ // match SSR). One main-thread pass globs + transforms every .module.css into a
7
+ // maps dict; the Bun plugin / Node loader / rolldown plugin are then dumb lookups
8
+ // into it, and the served/emitted CSS comes from the SAME pass — so the JS maps
9
+ // and the CSS agree by construction. Names stay machine-independent because the
10
+ // transform is keyed on an APP-RELATIVE path (stableKey), not an absolute one.
12
11
  //
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.
12
+ // Engine: Lightning CSS (Rust) — local scoping (default), `:global(...)`, and
13
+ // cross-file `composes`. Unlike postcss-modules, Lightning resolves `composes` by
14
+ // REFERENCE (it records the composed scoped name; it does NOT inline the rule), so
15
+ // a class composed across N files lands ONCE in the sheet with no dedup pass.
16
+ // Lightning is imported lazily and only runs at build/dev time, so it never enters
17
+ // the worker graph.
17
18
 
18
- import { createHash } from "node:crypto";
19
19
  import { readFile, readdir } from "node:fs/promises";
20
- import { join, relative, sep } from "node:path";
21
- import type { AcceptedPlugin, ChildNode } from "postcss";
20
+ import { dirname, join, relative, resolve as resolvePath, sep } from "node:path";
22
21
 
23
22
  export const MODULE_STYLES_URL = "/_june/modules.css";
24
23
 
@@ -29,35 +28,89 @@ export function stableKey(appRoot: string, file: string): string {
29
28
  return relative(appRoot, file).split(sep).join("/");
30
29
  }
31
30
 
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}`;
31
+ // One `composes` entry, as Lightning reports it: a class in this file (local, already
32
+ // scoped), a global class (unscoped, applied as-is), or a class in another module
33
+ // file (dependency — resolved against that file's exports).
34
+ type Compose =
35
+ | { type: "local"; name: string }
36
+ | { type: "global"; name: string }
37
+ | { type: "dependency"; name: string; specifier: string };
38
+ type ModuleExports = Record<string, { name: string; composes: Compose[] }>;
39
+ type Transformed = { css: string; exports: ModuleExports };
40
+
41
+ // Lightning-transform ONE file: deterministic scoped CSS + its exports. Cross-file
42
+ // `composes` come back as unresolved `dependency` refs — the caller resolves them.
43
+ async function transformOne(file: string, appRoot: string): Promise<Transformed> {
44
+ const { transform } = await import("lightningcss");
45
+ const { code, exports } = transform({
46
+ // The STABLE app-relative path drives Lightning's [hash], so names are
47
+ // machine-independent and identical across dev / worker build / client build.
48
+ filename: stableKey(appRoot, file),
49
+ code: Buffer.from(await readFile(file, "utf8")),
50
+ cssModules: { pattern: "[local]_[hash]" },
51
+ });
52
+ return { css: code.toString(), exports: (exports ?? {}) as ModuleExports };
53
+ }
54
+
55
+ // Transform `entry` and (recursively) every file it composes from, into `all`.
56
+ async function transformGraph(
57
+ entry: string,
58
+ appRoot: string,
59
+ all: Record<string, Transformed> = {},
60
+ ): Promise<Record<string, Transformed>> {
61
+ if (all[entry]) return all;
62
+ const t = await transformOne(entry, appRoot);
63
+ all[entry] = t;
64
+ for (const exp of Object.values(t.exports)) {
65
+ for (const c of exp.composes) {
66
+ if (c.type === "dependency") {
67
+ await transformGraph(resolvePath(dirname(entry), c.specifier), appRoot, all);
68
+ }
69
+ }
70
+ }
71
+ return all;
72
+ }
73
+
74
+ // The space-joined scoped class string for one local name, following composes:
75
+ // local → already scoped · global → as-is · dependency → the target file's name.
76
+ function resolveName(
77
+ file: string,
78
+ local: string,
79
+ all: Record<string, ModuleExports>,
80
+ seen = new Set<string>(),
81
+ ): string {
82
+ const exp = all[file]?.[local];
83
+ if (!exp) return local; // not a module class (shouldn't happen) → pass through
84
+ const parts = [exp.name];
85
+ for (const c of exp.composes) {
86
+ if (c.type === "dependency") {
87
+ const target = resolvePath(dirname(file), c.specifier);
88
+ const k = `${target}::${c.name}`;
89
+ if (seen.has(k)) continue; // guard against circular composes
90
+ seen.add(k);
91
+ parts.push(resolveName(target, c.name, all, seen));
92
+ } else {
93
+ parts.push(c.name); // local (already scoped) or global (unscoped)
94
+ }
95
+ }
96
+ return parts.join(" ");
35
97
  }
36
98
 
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.
99
+ const exportsOnly = (all: Record<string, Transformed>): Record<string, ModuleExports> =>
100
+ Object.fromEntries(Object.entries(all).map(([f, t]) => [f, t.exports]));
101
+
102
+ // Transform one .module.css (+ its composes deps) → its class map and scoped CSS.
103
+ // The CSS is THIS file's rules only; composed rules live in their own files (and so
104
+ // appear once in the combined sheet), never inlined here.
42
105
  export async function transformCssModule(
43
106
  file: string,
44
107
  appRoot: string,
45
108
  ): 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 };
109
+ const all = await transformGraph(file, appRoot);
110
+ const exports = exportsOnly(all);
111
+ const map: Record<string, string> = {};
112
+ for (const local of Object.keys(all[file]!.exports)) map[local] = resolveName(file, local, exports);
113
+ return { map, css: all[file]!.css };
61
114
  }
62
115
 
63
116
  // The JS a .module.css import resolves to.
@@ -85,50 +138,33 @@ async function findModuleCss(dir: string): Promise<string[]> {
85
138
  return out.sort();
86
139
  }
87
140
 
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
141
  // One pass over app/**/*.module.css → the per-file class maps AND the combined
117
142
  // scoped stylesheet. null css when there are none. Deterministic, so the maps the
118
- // loaders hand out match the CSS that's served/emitted.
143
+ // loaders hand out match the CSS that's served/emitted. composes are references
144
+ // (not inlined), so each rule appears once — no dedup pass.
119
145
  export async function buildModuleCss(
120
146
  appDir: string,
121
147
  appRoot: string,
122
148
  ): Promise<{ maps: ModuleMaps; css: string | null }> {
123
149
  const files = await findModuleCss(appDir);
150
+ if (!files.length) return { maps: {}, css: null };
151
+
152
+ const all: Record<string, Transformed> = {};
153
+ for (const file of files) await transformGraph(file, appRoot, all); // picks up out-of-glob deps too
154
+ const exports = exportsOnly(all);
155
+
124
156
  const maps: ModuleMaps = {};
125
- const parts: string[] = [];
126
157
  for (const file of files) {
127
- const { map, css } = await transformCssModule(file, appRoot);
128
- maps[file] = map;
129
- parts.push(css);
158
+ maps[file] = {};
159
+ for (const local of Object.keys(all[file]!.exports)) maps[file][local] = resolveName(file, local, exports);
130
160
  }
131
- return { maps, css: parts.length ? await dedupeRules(parts.join("\n")) : null };
161
+ // Emit each transformed file's CSS once (sorted), so a composed rule — which lives
162
+ // in its own file — appears exactly once in the combined sheet.
163
+ const css = Object.keys(all)
164
+ .sort()
165
+ .map((f) => all[f]!.css)
166
+ .join("\n");
167
+ return { maps, css };
132
168
  }
133
169
 
134
170
  // --- the three dumb interception points (all lookups into `maps`) ------------
package/src/db.ts CHANGED
@@ -1,35 +1,38 @@
1
1
  // `db` resource adapters — implementations of the @junejs/core JuneDb contract.
2
2
  // Declared in june.config.ts (`resources.db: sqlite(...)` / `d1(...)`), opened
3
- // by the host, injected onto RouteContext as `ctx.db`. The framework depends on
4
- // the JuneDb contract; these adapters (and Juno on top) are swappable.
3
+ // by the host and made ambient (`import { db } from "@junejs/db"`, via
4
+ // runInScope — NOT on ctx). The framework depends on the JuneDb contract; these
5
+ // adapters (and Juno on top) are swappable.
5
6
  //
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.
10
-
11
- import { mkdirSync } from "node:fs";
12
- import { dirname } from "node:path";
7
+ // This module is HOST-FREE so the generated worker can import it (e.g. `turso`)
8
+ // without dragging node:fs / the dev host into the bundle (reminder #4). The two
9
+ // host-coupled adapters keep their host touch out of the static graph:
10
+ // - d1() lives in ./d1 (edge-safe; env binding).
11
+ // - sqlite() is DEV-only and imports node:fs + ./host LAZILY, inside open().
12
+ // All five (sqlite/d1/postgres/mysql/turso) are named from this one place.
13
13
 
14
14
  import type { DbFactory } from "@junejs/core/resources";
15
15
 
16
- import { host } from "./host";
17
-
18
16
  export { d1, type D1Database } from "./d1";
19
17
  export { postgres, pgJuneDb, type PgClientLike } from "./postgres-driver";
20
18
  export { mysql, mysqlJuneDb, type MysqlConnLike } from "./mysql-driver";
19
+ export { turso, libsqlJuneDb, type LibsqlClientLike } from "./turso-driver";
21
20
 
22
21
  // Local SQLite — the zero-config dev default. A persistent FILE, not :memory:,
23
22
  // on purpose: `june dev` restarts the process on every save (the watch
24
23
  // supervisor), so an in-memory default would evaporate your dev data at each
25
24
  // edit. A file also makes the dev db inspectable with the sqlite3 every
26
25
  // machine already has (`sqlite3 .june/dev.sqlite`). Pass ":memory:" explicitly
27
- // for ephemerality.
26
+ // for ephemerality. node:fs + ./host are imported lazily so this module stays
27
+ // host-free (sqlite() only runs in dev, where the imports resolve).
28
28
  export function sqlite(opts: { path?: string } = {}): DbFactory {
29
29
  const path = opts.path ?? ".june/dev.sqlite";
30
30
  return {
31
31
  kind: "sqlite",
32
- open: () => {
32
+ open: async () => {
33
+ const { mkdirSync } = await import("node:fs");
34
+ const { dirname } = await import("node:path");
35
+ const { host } = await import("./host");
33
36
  if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
34
37
  return host.openDb(path);
35
38
  },
package/src/host.ts CHANGED
@@ -49,7 +49,7 @@ export interface JuneHost {
49
49
  // this with the in-isolate dual-graph loader; the seam stays for the fallback.
50
50
  spawnModule(entry: string, args: string[], opts: { conditions?: string[] }): SpawnedModule;
51
51
  // Open a LOCAL SQLite database — the internal primitive the `sqlite()` db
52
- // adapter builds on (docs/data-layer-boundary.md: openDb is demoted from the
52
+ // adapter builds on (docs/data-layer.md: openDb is demoted from the
53
53
  // user-facing API to a host primitive; apps declare `resources.db` instead).
54
54
  openDb(path: string): Promise<JuneDb>;
55
55
  }
package/src/index.ts CHANGED
@@ -11,7 +11,7 @@ export {
11
11
  type AdapterEmitContext,
12
12
  } from "./adapter";
13
13
  export { host, type JuneHost, type JuneDb, type RunResult, type ServeHandle, type SpawnedModule } from "./host";
14
- export { sqlite, d1, postgres, mysql, type D1Database } from "./db";
14
+ export { sqlite, d1, postgres, mysql, turso, type D1Database } from "./db";
15
15
  export { memoryKv, redisKv } from "./kv";
16
16
  export { localBlob, r2, type R2Bucket } from "./blob";
17
17
  export {
package/src/kv.ts CHANGED
@@ -1,4 +1,4 @@
1
- // `kv` resource adapters. The kv resource IS the cache (docs/data-layer-boundary
1
+ // `kv` resource adapters. The kv resource IS the cache (docs/data-layer
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
4
  // so there is ONE key-value system, surfaced both as cache() and as ambient kv.
package/src/resources.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  // Resource resolution — open the handles declared in june.config.ts `resources`
2
- // and hand them to the pipeline, which injects them onto RouteContext (ctx.db /
3
- // ctx.kv / ctx.blob). A resource not declared is never opened (and, for static
4
- // apps, tree-shaken out — the build freeze knows which routes touch resources).
2
+ // and hand them to the pipeline, which runs each request inside runInScope() so
3
+ // they resolve AMBIENTLY (`import { db } from "@junejs/db"`). ctx never carries
4
+ // them — ctx is identity-only (see route.ts). A resource not declared is never
5
+ // opened (and, for static apps, tree-shaken out — the build freeze knows which
6
+ // routes touch resources).
5
7
 
6
8
  import type { ResourceConfig, Resources } from "@junejs/core/resources";
7
9
 
@@ -48,7 +50,7 @@ export type ResourceFlags = { db?: boolean; kv?: boolean; blob?: boolean };
48
50
  // from env (env.DB → D1) — the prod half of "sqlite dev → D1 prod, one
49
51
  // declaration": the same `resources: { db: sqlite() }` runs on D1 at the edge,
50
52
  // because D1 *is* SQLite (same SQL, same Juno tables). No env binding → the
51
- // handle is absent and the route degrades (ctx.db undefined), never a crash.
53
+ // handle is absent and the route degrades (ambient `db` unavailable), never a crash.
52
54
  // Memoized per isolate (env is stable across requests in a workerd isolate).
53
55
  //
54
56
  // env is typed `unknown` (it arrives untyped from the runtime) and narrowed to
@@ -0,0 +1,96 @@
1
+ // Turso / libsql `db` adapter — implements the @junejs/core JuneDb contract over
2
+ // @libsql/client. Turso speaks libsql (SQLite over an HTTPS endpoint), so it tags
3
+ // itself `dialect: "sqlite"`: Juno compiles the exact same `?`-placeholder SQLite
4
+ // SQL it uses for the local sqlite() adapter, and the connection is a plain URL +
5
+ // token (no TCP socket, no platform binding) — so it runs on the Vercel Node
6
+ // runtime, on edge, and anywhere with fetch. `@libsql/client` is dynamically
7
+ // imported: a peer the app installs only when it declares turso(); the host never
8
+ // hard-depends on it. Host-free (no node:* / ./host) so it can enter a worker
9
+ // bundle, unlike sqlite().
10
+
11
+ import type { DbFactory, JuneDb, RunResult } from "@junejs/core/resources";
12
+
13
+ // The slice of @libsql/client's Client we depend on — so libsqlJuneDb is
14
+ // unit-testable with a fake (no network).
15
+ export interface LibsqlResultSet {
16
+ rows: unknown[];
17
+ rowsAffected: number;
18
+ lastInsertRowid?: bigint | number;
19
+ }
20
+ export interface LibsqlClientLike {
21
+ execute(stmt: { sql: string; args?: unknown[] } | string): Promise<LibsqlResultSet>;
22
+ executeMultiple?(sql: string): Promise<void>;
23
+ close(): void | Promise<void>;
24
+ }
25
+
26
+ // Wrap a libsql client as a JuneDb. Exported (not just the factory) so the mapping
27
+ // — rows, first-row, RunResult, transaction ordering — is testable offline.
28
+ export function libsqlJuneDb(client: LibsqlClientLike): JuneDb {
29
+ const self: JuneDb = {
30
+ dialect: "sqlite", // libsql IS sqlite — same SQL + Juno tables as sqlite()/d1()
31
+ async query<T>(sql: string, params: unknown[] = []) {
32
+ return (await client.execute({ sql, args: params })).rows as T[];
33
+ },
34
+ async get<T>(sql: string, params: unknown[] = []) {
35
+ return ((await client.execute({ sql, args: params })).rows[0] ?? undefined) as T | undefined;
36
+ },
37
+ async run(sql: string, params: unknown[] = []): Promise<RunResult> {
38
+ const r = await client.execute({ sql, args: params });
39
+ // libsql returns lastInsertRowid as a bigint; RunResult is number-ish.
40
+ return { changes: r.rowsAffected ?? 0, lastInsertRowid: Number(r.lastInsertRowid ?? 0) };
41
+ },
42
+ async exec(sql: string) {
43
+ // DDL / migrations are multi-statement; executeMultiple splits on `;`.
44
+ if (client.executeMultiple) await client.executeMultiple(sql);
45
+ else await client.execute(sql);
46
+ },
47
+ async transaction<T>(fn: (tx: JuneDb) => Promise<T>) {
48
+ // Test/dev grade, matching postgres()/mysql(): BEGIN/COMMIT on the one
49
+ // client. (For strict isolation over HTTP, libsql's interactive
50
+ // client.transaction() is the upgrade path.)
51
+ await client.execute("BEGIN");
52
+ try {
53
+ const out = await fn(self);
54
+ await client.execute("COMMIT");
55
+ return out;
56
+ } catch (e) {
57
+ await client.execute("ROLLBACK");
58
+ throw e;
59
+ }
60
+ },
61
+ async close() {
62
+ await client.close();
63
+ },
64
+ };
65
+ return self;
66
+ }
67
+
68
+ // `turso({ url, authToken })` — declared in june.config.ts, or omit both to read
69
+ // the standard TURSO_DATABASE_URL / TURSO_AUTH_TOKEN env vars at open() time
70
+ // (NOT at construction — so the build can evaluate the config without the secrets
71
+ // present, and the deployed function reads them from its own environment).
72
+ export function turso(opts: { url?: string; authToken?: string } = {}): DbFactory {
73
+ return {
74
+ kind: "turso",
75
+ open: async () => {
76
+ const url = opts.url ?? readEnv("TURSO_DATABASE_URL");
77
+ const authToken = opts.authToken ?? readEnv("TURSO_AUTH_TOKEN");
78
+ if (!url) {
79
+ throw new Error(
80
+ "turso(): no database url — pass { url } or set TURSO_DATABASE_URL in the environment.",
81
+ );
82
+ }
83
+ // The pure-fetch web client: works in dev (Bun) AND bundles into the one
84
+ // deployed function (the node client would pull native bindings). Turso is
85
+ // remote (libsql:// over HTTPS), which is exactly what the web client speaks.
86
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
87
+ const mod = (await import("@libsql/client/web")) as any;
88
+ const createClient = (mod.default ?? mod).createClient;
89
+ return libsqlJuneDb(createClient({ url, authToken }));
90
+ },
91
+ };
92
+ }
93
+
94
+ function readEnv(name: string): string | undefined {
95
+ return typeof process !== "undefined" && process.env ? process.env[name] : undefined;
96
+ }