@junejs/juno 0.0.13 → 0.0.14

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/README.md CHANGED
@@ -1,27 +1,68 @@
1
1
  # @junejs/juno
2
2
 
3
- June's ergonomic data layer — a typed table API over the pure `JuneDb` contract
4
- (`@junejs/core/resources`), so it runs over sqlite / D1 / Postgres alike. Juno
5
- depends only on `@junejs/core` (inward); `@junejs/core` never imports Juno.
3
+ June's ergonomic data layer — a typed table API over the `JuneDb` contract, so it
4
+ runs over sqlite / D1 / Postgres alike. **Optional Tier 3:** a minimal app just uses
5
+ the raw `db` resource (`db.query(sql, params)`); declare Juno to get the magic
6
+ (auto-batch, auto-invalidation, compile-once). Depends on `@junejs/core` +
7
+ `@junejs/db` (inward); the framework never imports Juno.
8
+
9
+ ## Use it
10
+
11
+ Opt in once, in `june.config.ts`:
12
+
13
+ ```ts
14
+ import { defineJune } from "@junejs/core/config";
15
+ import { sqlite } from "@junejs/server";
16
+ import { junoDataLayer } from "@junejs/juno";
17
+
18
+ export default defineJune({ resources: { db: sqlite() }, dataLayer: junoDataLayer() });
19
+ ```
20
+
21
+ Then, ambiently — no handle to thread, `ctx` stays identity-only; use inside a
22
+ request (a loader/view/action):
6
23
 
7
24
  ```ts
8
- import { juno } from "@junejs/juno";
25
+ import { table } from "@junejs/juno";
26
+ import { db } from "@junejs/server"; // the one canonical handle
9
27
 
10
- const db = juno(ctx.db); // ctx.db is the injected `db` resource
11
- await db.table("users").all();
12
- await db.table("users").findBy({ email: "ada@x.dev" });
13
- await db.table("users").insert({ name: "Ada" });
14
- await db.table("users").update({ id: 1 }, { name: "Ada Lovelace" });
28
+ await table("users").findBy({ email: "ada@x.dev" }); // point read (auto-batches)
29
+ await table("posts").all(
30
+ { user_id: 7, created_at: { gte: cutoff }, title: { like: "%june%" } },
31
+ { orderBy: { created_at: "desc" }, limit: 20, offset: 0 },
32
+ );
33
+ await table("users").upsert({ email: "ada@x.dev", name: "Ada" }, { onConflict: "email" });
34
+ await db.query("select * from users where age > ?", [18]); // raw escape hatch (auto-tags)
15
35
  ```
16
36
 
17
- ## The magic is opt-in, not load-bearing
37
+ ## Non-obvious facts (the easy-to-get-wrong bits)
38
+
39
+ The API shape is self-explanatory; these are the things models and humans get
40
+ backwards. High-signal on purpose — read these, skip re-deriving the obvious.
41
+
42
+ - **Write the naive thing — the automatic layer makes it fast/correct.** Don't
43
+ hand-roll caching or batching loops on top; that's what Juno is for.
44
+ - **`findBy` / `all` by a single column auto-batch per request.** Naive per-component
45
+ reads coalesce into one `where col in (...)` — do NOT manually collect ids to batch.
46
+ - **Use `upsert(values, { onConflict })`** — atomic, one round trip, returns the row.
47
+ NOT `findBy`-then-`insert` (an extra round trip and a race).
48
+ - **Auto-invalidation fires at the ACTION boundary, not on a write.** A mutation must
49
+ be a `defineAction`; a direct `insert`/`update` outside an action invalidates no
50
+ `cache()`. Don't expect a bare write to refresh a cached read.
51
+ - **On D1 / edge, round trips dominate — not the engine.** Auto-batch kills
52
+ *sequential* N+1 (the catastrophe). Versus *concurrent* reads the win is modest —
53
+ concurrency already hides latency. Never go sequential; don't over-credit
54
+ batch-vs-concurrent.
55
+ - **OR and joins → the raw `db` escape hatch.** `all()` covers AND-of-operators
56
+ (`eq/ne/gt/gte/lt/lte/in/like`) + order / limit / offset; raw SQL is first-class
57
+ for the rest, and the canonical `db` auto-tags it (so a raw read inside `cache()`
58
+ is invalidated by a write, not silently stale).
59
+
60
+ ## Tiers (Juno is optional)
18
61
 
19
- Juno is the **default** that ships at Tier 3: every read calls `recordTableRead`
20
- and every write `recordTableWrite` (@junejs/core's public trace contract). That is
21
- what makes `cache()` auto-tag by table and a mutation **auto-invalidate** the
22
- cache (and push live RSC) with zero manual `revalidate()`.
62
+ The framework works fully without Juno. **Tier 1** = bring your own (Prisma/Drizzle),
63
+ untouched. **Tier 2** = your ORM over the ambient `db` resource. **Tier 3** = Juno
64
+ (or any thin shim emitting `recordTableRead`/`recordTableWrite`) for the
65
+ auto-invalidate / auto-batch / live-RSC magic. See `docs/data-layer-boundary.md`.
23
66
 
24
- This is a property of emitting the trace signals, **not** of using Juno. The
25
- framework depends on the contract, never on Juno — so Prisma/Drizzle stay
26
- first-class (Tier 1 = bring your own, untouched; Tier 2 = your ORM over `ctx.db`;
27
- Tier 3 = the same magic via a thin shim). See `docs/data-layer-boundary.md`.
67
+ For tests or bring-your-own wiring, the explicit handle still works:
68
+ `const j = juno(someDb); await j.table("users").all()`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/juno",
3
- "version": "0.0.13",
3
+ "version": "0.0.14",
4
4
  "description": "Juno — June's ergonomic data layer over the JuneDb contract. The default that ships with the agent-native magic (Tier 3).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,10 +16,11 @@
16
16
  "typecheck": "tsc --noEmit"
17
17
  },
18
18
  "dependencies": {
19
- "@junejs/core": "0.0.13"
19
+ "@junejs/core": "0.0.14",
20
+ "@junejs/db": "0.0.14"
20
21
  },
21
22
  "devDependencies": {
22
- "@junejs/server": "0.0.13"
23
+ "@junejs/server": "0.0.14"
23
24
  },
24
25
  "repository": {
25
26
  "type": "git",
package/src/ast.ts ADDED
@@ -0,0 +1,57 @@
1
+ // Operation-node AST — the immutable shape a query compiles from. Stage 1 covers
2
+ // exactly what the table API emits today (select with equality `where` + optional
3
+ // limit, insert, update, delete, upsert); Stage 2 grows where-operators / joins /
4
+ // order. Nodes carry SHAPE only (table + column names), never parameter values —
5
+ // so a node IS its own structural cache key for compile-once. A dialect compiler
6
+ // (compiler.ts) walks these; placeholders/quoting/upsert syntax vary by subclass.
7
+
8
+ export type Node = SelectNode | InsertNode | UpdateNode | DeleteNode | UpsertNode;
9
+
10
+ // Comparison operators a WHERE predicate can use (Stage 2). `in` is variadic — its
11
+ // placeholder count is the value array's length, so it carries `arity` (which makes
12
+ // the SQL shape, hence the compile-once key, depend on the list length).
13
+ export type CmpOp = "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "like";
14
+ export type Predicate = { col: string; op: CmpOp } | { col: string; op: "in"; arity: number };
15
+ export type OrderTerm = { col: string; dir: "asc" | "desc" };
16
+
17
+ // `select * from <from> [where <pred> and ...] [order by ...] [limit ?] [offset ?]`.
18
+ // Predicates are AND-joined (OR → the raw escape hatch). `limit` is a literal number
19
+ // (findBy's `limit 1`) or "param" (bound, so all limits share one compiled SQL);
20
+ // `offset` is always bound.
21
+ export type SelectNode = {
22
+ kind: "select";
23
+ from: string;
24
+ where: Predicate[];
25
+ orderBy?: OrderTerm[];
26
+ limit?: number | "param";
27
+ offset?: "param";
28
+ };
29
+
30
+ export type InsertNode = {
31
+ kind: "insert";
32
+ into: string;
33
+ columns: string[];
34
+ };
35
+
36
+ export type UpdateNode = {
37
+ kind: "update";
38
+ table: string;
39
+ set: string[];
40
+ where: string[];
41
+ };
42
+
43
+ export type DeleteNode = {
44
+ kind: "delete";
45
+ from: string;
46
+ where: string[];
47
+ };
48
+
49
+ // `insert into <into> (cols) values (?) on conflict (conflict) do update set
50
+ // <update[i]> = excluded.<update[i]> returning *`.
51
+ export type UpsertNode = {
52
+ kind: "upsert";
53
+ into: string;
54
+ columns: string[];
55
+ conflict: string[];
56
+ update: string[];
57
+ };
package/src/batch.ts CHANGED
@@ -71,3 +71,72 @@ export function tableLoader<V extends Row = Row>(
71
71
  (row) => (row as Row)[key] as string | number,
72
72
  );
73
73
  }
74
+
75
+ export type ListLoader<K, V> = {
76
+ load(key: K): Promise<V[]>;
77
+ };
78
+
79
+ // Like createLoader, but resolves ALL rows for a key — the list shape (e.g. a
80
+ // user's posts), where `where key in (...)` returns many rows per key.
81
+ export function createGroupLoader<K, V>(
82
+ batchFn: (keys: K[]) => Promise<V[]>,
83
+ keyOf: (value: V) => K,
84
+ ): ListLoader<K, V> {
85
+ let queue: { key: K; resolve: (v: V[]) => void; reject: (e: unknown) => void }[] = [];
86
+ let scheduled = false;
87
+
88
+ function flush() {
89
+ const batch = queue;
90
+ queue = [];
91
+ scheduled = false;
92
+ const keys = [...new Set(batch.map((b) => b.key))];
93
+ batchFn(keys).then(
94
+ (values) => {
95
+ const byKey = new Map<K, V[]>();
96
+ for (const v of values) {
97
+ const k = keyOf(v);
98
+ const list = byKey.get(k);
99
+ if (list) list.push(v);
100
+ else byKey.set(k, [v]);
101
+ }
102
+ for (const b of batch) b.resolve(byKey.get(b.key) ?? []);
103
+ },
104
+ (err) => {
105
+ for (const b of batch) b.reject(err);
106
+ },
107
+ );
108
+ }
109
+
110
+ return {
111
+ load(key: K) {
112
+ return new Promise<V[]>((resolve, reject) => {
113
+ queue.push({ key, resolve, reject });
114
+ if (!scheduled) {
115
+ scheduled = true;
116
+ queueMicrotask(flush);
117
+ }
118
+ });
119
+ },
120
+ };
121
+ }
122
+
123
+ // A by-key LIST loader over a JuneDb table: `loader.load(userId)` calls coalesce
124
+ // into one `select * from <table> where <key> in (...)`, each resolving to that
125
+ // key's rows. The list analog of tableLoader. Build one per request.
126
+ export function tableListLoader<V extends Row = Row>(
127
+ db: JuneDb,
128
+ table: string,
129
+ key = "id",
130
+ ): ListLoader<string | number, V> {
131
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
132
+ throw new Error(`unsafe SQL identifier: ${table}.${key}`);
133
+ }
134
+ return createGroupLoader<string | number, V>(
135
+ async (keys) => {
136
+ recordTableRead(table);
137
+ const placeholders = keys.map(() => "?").join(", ");
138
+ return db.query<V>(`select * from ${table} where ${key} in (${placeholders})`, keys);
139
+ },
140
+ (row) => (row as Row)[key] as string | number,
141
+ );
142
+ }
@@ -0,0 +1,134 @@
1
+ // The dialect compiler — walks an operation-node AST (ast.ts) into a SQL string.
2
+ // A `Dialect` is the seam multi-dialect lands on: subclasses override placeholder
3
+ // style / quoting / upsert syntax. SqliteDialect (sqlite + D1) is the default; a
4
+ // PostgresDialect (`$n` placeholders) slots in later as another subclass.
5
+ //
6
+ // Compile-once: `compile()` memoizes by the node's structural key. Nodes hold
7
+ // shape only (no parameter values), so the same query shape compiles ONCE and
8
+ // every later call reuses the string — what Kysely-style builders don't do (they
9
+ // recompile per execute). Param VALUES are bound by the caller, never cached.
10
+
11
+ import type { CmpOp, Node, Predicate } from "./ast";
12
+
13
+ const SYM: Record<CmpOp, string> = {
14
+ eq: "=",
15
+ ne: "!=",
16
+ gt: ">",
17
+ gte: ">=",
18
+ lt: "<",
19
+ lte: "<=",
20
+ like: "like",
21
+ };
22
+
23
+ // Emit one WHERE predicate. `ph()` yields the next placeholder (and advances the
24
+ // dialect's counter), so `in` can consume `arity` of them in order.
25
+ function predicate(p: Predicate, ph: () => string): string {
26
+ if (p.op === "in") {
27
+ return `${ident(p.col)} in (${Array.from({ length: p.arity }, ph).join(", ")})`;
28
+ }
29
+ return `${ident(p.col)} ${SYM[p.op]} ${ph()}`;
30
+ }
31
+
32
+ // Guard table/column names — identifiers can't be parameterized. Values always go
33
+ // through bound placeholders, so they are injection-safe by construction.
34
+ export function ident(name: string): string {
35
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(`unsafe SQL identifier: ${name}`);
36
+ return name;
37
+ }
38
+
39
+ // A compact structural key: kind + table + column/clause shape. Excludes values
40
+ // (nodes carry none), so structurally-equal queries share one compiled string.
41
+ function keyOf(node: Node): string {
42
+ switch (node.kind) {
43
+ case "select":
44
+ return (
45
+ `s|${node.from}|` +
46
+ node.where.map((p) => (p.op === "in" ? `${p.col}:in${p.arity}` : `${p.col}:${p.op}`)).join("&") +
47
+ `|${(node.orderBy ?? []).map((o) => `${o.col} ${o.dir}`).join(",")}` +
48
+ `|${node.limit ?? ""}|${node.offset ?? ""}`
49
+ );
50
+ case "insert":
51
+ return `i|${node.into}|${node.columns.join(",")}`;
52
+ case "update":
53
+ return `u|${node.table}|${node.set.join(",")}|${node.where.join(",")}`;
54
+ case "delete":
55
+ return `d|${node.from}|${node.where.join(",")}`;
56
+ case "upsert":
57
+ return `x|${node.into}|${node.columns.join(",")}|${node.conflict.join(",")}|${node.update.join(",")}`;
58
+ }
59
+ }
60
+
61
+ export abstract class Dialect {
62
+ // Process-level (a SqliteDialect singleton is shared across requests): the
63
+ // compiled SQL is data-free, so caching it across requests is safe and is the
64
+ // whole point of compile-once.
65
+ private readonly cache = new Map<string, string>();
66
+
67
+ compile(node: Node): string {
68
+ const key = keyOf(node);
69
+ let sql = this.cache.get(key);
70
+ if (sql === undefined) {
71
+ sql = this.emit(node); // throws on a bad identifier before caching
72
+ this.cache.set(key, sql);
73
+ }
74
+ return sql;
75
+ }
76
+
77
+ // The i-th bound placeholder (1-based). Sqlite ignores i (`?`); Postgres uses `$i`.
78
+ protected abstract placeholder(i: number): string;
79
+
80
+ protected emit(node: Node): string {
81
+ switch (node.kind) {
82
+ case "select": {
83
+ let i = 0;
84
+ const ph = () => this.placeholder(++i);
85
+ const where = node.where.length
86
+ ? ` where ${node.where.map((p) => predicate(p, ph)).join(" and ")}`
87
+ : "";
88
+ const order = node.orderBy?.length
89
+ ? ` order by ${node.orderBy.map((o) => `${ident(o.col)} ${o.dir === "desc" ? "desc" : "asc"}`).join(", ")}`
90
+ : "";
91
+ const limit = node.limit === "param" ? ` limit ${ph()}` : node.limit != null ? ` limit ${node.limit}` : "";
92
+ const offset = node.offset === "param" ? ` offset ${ph()}` : "";
93
+ return `select * from ${ident(node.from)}${where}${order}${limit}${offset}`;
94
+ }
95
+ case "insert": {
96
+ let i = 0;
97
+ const cols = node.columns.map(ident);
98
+ const vals = node.columns.map(() => this.placeholder(++i));
99
+ return `insert into ${ident(node.into)} (${cols.join(", ")}) values (${vals.join(", ")})`;
100
+ }
101
+ case "update": {
102
+ let i = 0;
103
+ const set = node.set.map((c) => `${ident(c)} = ${this.placeholder(++i)}`).join(", ");
104
+ const where = node.where.map((c) => `${ident(c)} = ${this.placeholder(++i)}`).join(" and ");
105
+ return `update ${ident(node.table)} set ${set} where ${where}`;
106
+ }
107
+ case "delete": {
108
+ let i = 0;
109
+ const where = node.where.map((c) => `${ident(c)} = ${this.placeholder(++i)}`).join(" and ");
110
+ return `delete from ${ident(node.from)} where ${where}`;
111
+ }
112
+ case "upsert": {
113
+ let i = 0;
114
+ const cols = node.columns.map(ident);
115
+ const vals = node.columns.map(() => this.placeholder(++i));
116
+ const set = node.update.map((c) => `${ident(c)} = excluded.${ident(c)}`).join(", ");
117
+ return (
118
+ `insert into ${ident(node.into)} (${cols.join(", ")}) values (${vals.join(", ")}) ` +
119
+ `on conflict (${node.conflict.map(ident).join(", ")}) do update set ${set} returning *`
120
+ );
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ export class SqliteDialect extends Dialect {
127
+ protected placeholder(): string {
128
+ return "?";
129
+ }
130
+ }
131
+
132
+ // The default dialect (sqlite + D1). A shared singleton so its compile-once cache
133
+ // is process-level. Postgres adds a sibling subclass later.
134
+ export const sqlite = new SqliteDialect();
package/src/index.ts CHANGED
@@ -11,19 +11,41 @@
11
11
  import type { JuneDb, RunResult } from "@junejs/core/resources";
12
12
  import { recordTableRead, recordTableWrite } from "@junejs/core/instrumentation";
13
13
 
14
- import { tableLoader, type Loader } from "./batch";
15
- import { taggingDb } from "./tag";
14
+ import type { DataLayer } from "@junejs/core/config";
15
+ import { db as ambientDb, requestLocal, registerSqlTagger } from "@junejs/db";
16
16
 
17
- export { createLoader, tableLoader, type Loader } from "./batch";
17
+ import type { Predicate, SelectNode } from "./ast";
18
+ import { tableLoader, tableListLoader, type Loader, type ListLoader } from "./batch";
19
+ import { sqlite } from "./compiler";
20
+ import { taggingDb, tagSql } from "./tag";
21
+
22
+ export { createLoader, createGroupLoader, tableLoader, tableListLoader, type Loader, type ListLoader } from "./batch";
18
23
  export { tablesFromSql, tagSql, taggingDb, type SqlTouch } from "./tag";
24
+ export { Dialect, SqliteDialect, sqlite, ident } from "./compiler";
25
+ export type { Node, SelectNode, InsertNode, UpdateNode, DeleteNode, UpsertNode } from "./ast";
19
26
 
20
27
  export type Row = Record<string, unknown>;
21
28
 
22
- // Guard table/column names (identifiers can't be parameterized). Values always
23
- // go through bound `?` placeholders, so they are injection-safe by construction.
24
- function ident(name: string): string {
25
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(`unsafe SQL identifier: ${name}`);
26
- return name;
29
+ // A WHERE value is either an equality value or an operator object (Stage 2).
30
+ // `{ age: { gte: 18 }, name: { like: "%a%" }, id: { in: [1, 2] } }` — AND-joined.
31
+ export type Operators = {
32
+ eq?: unknown;
33
+ ne?: unknown;
34
+ gt?: unknown;
35
+ gte?: unknown;
36
+ lt?: unknown;
37
+ lte?: unknown;
38
+ in?: unknown[];
39
+ like?: string;
40
+ };
41
+ export type Where<T> = { [K in keyof T]?: T[K] | Operators };
42
+ export type OrderBy<T> = { [K in keyof T]?: "asc" | "desc" };
43
+
44
+ const OPS = new Set(["eq", "ne", "gt", "gte", "lt", "lte", "in", "like"]);
45
+ // An operator object is a non-array object (sqlite values are primitives, so a
46
+ // non-array object in a WHERE position means operators, not a bound value).
47
+ function isOperators(v: unknown): v is Operators {
48
+ return v != null && typeof v === "object" && !Array.isArray(v);
27
49
  }
28
50
 
29
51
  export class Table<T extends Row = Row> {
@@ -31,14 +53,67 @@ export class Table<T extends Row = Row> {
31
53
  private readonly db: JuneDb,
32
54
  private readonly name: string,
33
55
  // Per-request ambient-loader registry, shared across the juno() handle's
34
- // .table() calls so concurrent findBy coalesce. Optional/defaulted so a
35
- // standalone `new Table(db, name)` still works (it just batches alone).
36
- private readonly loaders: Map<string, Loader<string | number, Row>> = new Map(),
56
+ // .table() calls so concurrent findBy/all coalesce. Holds both point and list
57
+ // loaders (keyed apart). Optional/defaulted so a standalone `new Table(db,
58
+ // name)` still works (it just batches alone).
59
+ private readonly loaders: Map<string, unknown> = new Map(),
37
60
  ) {}
38
61
 
39
- async all(): Promise<T[]> {
62
+ // No `where` → every row. With a `where`, the list of rows matching it (the list
63
+ // counterpart of findBy, same where syntax). A single-column filter ambient-
64
+ // batches like findBy — concurrent `all({user_id})` across components coalesce
65
+ // into one `where user_id in (...)` grouped by key (list N+1 → 1).
66
+ async all(
67
+ where?: Where<T>,
68
+ opts?: { orderBy?: OrderBy<T>; limit?: number; offset?: number },
69
+ ): Promise<T[]> {
40
70
  recordTableRead(this.name); // auto-tag: a cache() around this gets table:<name>
41
- return this.db.query<T>(`select * from ${ident(this.name)}`);
71
+ const w = (where ?? {}) as Row;
72
+ const keys = Object.keys(w);
73
+ const [col] = keys;
74
+ // Ambient batch only for a single-column EQUALITY filter with no opts —
75
+ // operators / multi-column / order / limit fall through to a direct query.
76
+ if (!opts && keys.length === 1 && col !== undefined && !isOperators(w[col])) {
77
+ return this.ambientListLoader(col).load(w[col] as string | number) as Promise<T[]>;
78
+ }
79
+
80
+ const predicates: Predicate[] = [];
81
+ const params: unknown[] = [];
82
+ for (const c of keys) {
83
+ const v = w[c];
84
+ if (isOperators(v)) {
85
+ for (const op of Object.keys(v)) {
86
+ if (!OPS.has(op)) throw new Error(`unknown operator: ${op}`);
87
+ const operand = (v as Record<string, unknown>)[op];
88
+ if (op === "in") {
89
+ const arr = operand as unknown[];
90
+ predicates.push({ col: c, op: "in", arity: arr.length });
91
+ params.push(...arr);
92
+ } else {
93
+ predicates.push({ col: c, op: op as Exclude<keyof Operators, "in"> });
94
+ params.push(operand);
95
+ }
96
+ }
97
+ } else {
98
+ predicates.push({ col: c, op: "eq" });
99
+ params.push(v);
100
+ }
101
+ }
102
+
103
+ const orderBy = opts?.orderBy
104
+ ? (Object.entries(opts.orderBy) as [string, "asc" | "desc"][]).map(([c, dir]) => ({ col: c, dir }))
105
+ : undefined;
106
+ const node: SelectNode = {
107
+ kind: "select",
108
+ from: this.name,
109
+ where: predicates,
110
+ orderBy,
111
+ limit: opts?.limit != null ? "param" : undefined,
112
+ offset: opts?.offset != null ? "param" : undefined,
113
+ };
114
+ if (opts?.limit != null) params.push(opts.limit);
115
+ if (opts?.offset != null) params.push(opts.offset);
116
+ return this.db.query<T>(sqlite.compile(node), params);
42
117
  }
43
118
 
44
119
  async findBy(where: Partial<T>): Promise<T | undefined> {
@@ -54,9 +129,9 @@ export class Table<T extends Row = Row> {
54
129
  const hit = await this.ambientLoader(col).load((where as Row)[col] as string | number);
55
130
  return (hit ?? undefined) as T | undefined;
56
131
  }
57
- const cols = keys.map(ident);
58
- const clause = cols.length ? ` where ${cols.map((k) => `${k} = ?`).join(" and ")}` : "";
59
- return this.db.get<T>(`select * from ${ident(this.name)}${clause} limit 1`, Object.values(where));
132
+ const where2: Predicate[] = keys.map((k) => ({ col: k, op: "eq" }));
133
+ const sql = sqlite.compile({ kind: "select", from: this.name, where: where2, limit: 1 });
134
+ return this.db.get<T>(sql, Object.values(where));
60
135
  }
61
136
 
62
137
  // get-or-create the per-request ambient loader for (table, column). tableLoader
@@ -71,30 +146,55 @@ export class Table<T extends Row = Row> {
71
146
  return loader as Loader<string | number, T>;
72
147
  }
73
148
 
149
+ // get-or-create the per-request ambient LIST loader for (table, column). Keyed
150
+ // apart from the point loader (`list:` prefix) — they coalesce different shapes.
151
+ private ambientListLoader(col: string): ListLoader<string | number, T> {
152
+ const key = `list:${this.name}::${col}`;
153
+ let loader = this.loaders.get(key);
154
+ if (!loader) {
155
+ loader = tableListLoader<Row>(this.db, this.name, col);
156
+ this.loaders.set(key, loader);
157
+ }
158
+ return loader as ListLoader<string | number, T>;
159
+ }
160
+
74
161
  async insert(values: Partial<T>): Promise<RunResult> {
75
162
  recordTableWrite(this.name); // auto-invalidate: invokeAction drops table:<name>
76
- const cols = Object.keys(values).map(ident);
77
- const placeholders = cols.map(() => "?").join(", ");
78
- return this.db.run(
79
- `insert into ${ident(this.name)} (${cols.join(", ")}) values (${placeholders})`,
80
- Object.values(values),
81
- );
163
+ const sql = sqlite.compile({ kind: "insert", into: this.name, columns: Object.keys(values) });
164
+ return this.db.run(sql, Object.values(values));
165
+ }
166
+
167
+ // Atomic insert-or-update: insert `values`; on a conflict of the `onConflict`
168
+ // column(s), update the other provided columns to the new values. Returns the
169
+ // upserted row (via RETURNING) in ONE round trip — the primitive that removes
170
+ // the findBy-then-insert footgun (eval A2/A3). If every provided column is a
171
+ // conflict key, it no-ops the update so RETURNING still yields the existing row
172
+ // (a get-or-create).
173
+ async upsert(values: Partial<T>, opts: { onConflict: string | string[] }): Promise<T | undefined> {
174
+ recordTableWrite(this.name);
175
+ const columns = Object.keys(values);
176
+ const conflict = Array.isArray(opts.onConflict) ? opts.onConflict : [opts.onConflict];
177
+ const updates = columns.filter((c) => !conflict.includes(c));
178
+ const update = updates.length ? updates : conflict;
179
+ const sql = sqlite.compile({ kind: "upsert", into: this.name, columns, conflict, update });
180
+ return this.db.get<T>(sql, Object.values(values));
82
181
  }
83
182
 
84
183
  async update(where: Partial<T>, values: Partial<T>): Promise<RunResult> {
85
184
  recordTableWrite(this.name);
86
- const set = Object.keys(values).map((k) => `${ident(k)} = ?`).join(", ");
87
- const cond = Object.keys(where).map((k) => `${ident(k)} = ?`).join(" and ");
88
- return this.db.run(
89
- `update ${ident(this.name)} set ${set} where ${cond}`,
90
- [...Object.values(values), ...Object.values(where)],
91
- );
185
+ const sql = sqlite.compile({
186
+ kind: "update",
187
+ table: this.name,
188
+ set: Object.keys(values),
189
+ where: Object.keys(where),
190
+ });
191
+ return this.db.run(sql, [...Object.values(values), ...Object.values(where)]);
92
192
  }
93
193
 
94
194
  async delete(where: Partial<T>): Promise<RunResult> {
95
195
  recordTableWrite(this.name);
96
- const cond = Object.keys(where).map((k) => `${ident(k)} = ?`).join(" and ");
97
- return this.db.run(`delete from ${ident(this.name)} where ${cond}`, Object.values(where));
196
+ const sql = sqlite.compile({ kind: "delete", from: this.name, where: Object.keys(where) });
197
+ return this.db.run(sql, Object.values(where));
98
198
  }
99
199
 
100
200
  // A per-request by-key loader: concurrent .load(key) calls during one render
@@ -124,7 +224,7 @@ export type Juno = {
124
224
  // findBy across components coalesce — and scoping it to the per-request handle
125
225
  // is what keeps keys from leaking across requests.
126
226
  export function juno(db: JuneDb): Juno {
127
- const loaders = new Map<string, Loader<string | number, Row>>();
227
+ const loaders = new Map<string, unknown>();
128
228
  return {
129
229
  // Table API uses the raw db and tags explicitly (precise, parser-independent);
130
230
  // the exposed handle wraps it so the raw escape hatch auto-tags too.
@@ -140,3 +240,43 @@ export function juno(db: JuneDb): Juno {
140
240
  },
141
241
  };
142
242
  }
243
+
244
+ // --- Ambient surface (the framework-native way) --------------------------------
245
+ // Matches June's ambient resources (`import { db } from "@junejs/db"`): no handle
246
+ // to thread or mis-scope. `table()` reads the ambient `db` resource and a
247
+ // per-request loader registry kept in the request scope, so batching is
248
+ // STRUCTURALLY per-request and unstashable — the leak A3 found can't happen.
249
+ // Use inside a request scope (a loader/view/action); throws otherwise, like `db`.
250
+ const LOADERS = Symbol.for("junejs.juno.loaders");
251
+
252
+ // The boot wiring: register Juno's SQL tagger so the canonical ambient `db`
253
+ // auto-tags raw queries. `june build` imports THIS by name into the worker.
254
+ export function installDataLayer(): void {
255
+ registerSqlTagger(tagSql);
256
+ }
257
+
258
+ // Declare Juno as the app's data layer in june.config.ts: `dataLayer: junoDataLayer()`.
259
+ // The dev host calls install() at boot; `june build` emits installDataLayer() into
260
+ // the worker (via `module`). Explicit (config-declared), NOT an import-time global
261
+ // side-effect — and the framework still never imports Juno (the user's config does).
262
+ export function junoDataLayer(): DataLayer {
263
+ return { install: installDataLayer, module: "@junejs/juno" };
264
+ }
265
+
266
+ export function table<T extends Row = Row>(name: string): Table<T> {
267
+ const loaders = requestLocal(LOADERS, () => new Map<string, unknown>());
268
+ return new Table<T>(ambientDb, name, loaders);
269
+ }
270
+
271
+ // No separate ambient `db` export: the canonical `db` from `@junejs/db` (and
272
+ // re-exported by `@junejs/server`) auto-tags once Juno is imported (above), so
273
+ // there is exactly one `db` to import. `taggingDb` remains for the explicit
274
+ // `juno(handle)` path, where it wraps a caller-supplied handle.
275
+
276
+ // Explicit tag/invalidate hatch, ambiently (no handle needed — they only record).
277
+ export function reads(...tables: string[]): void {
278
+ for (const t of tables) recordTableRead(t);
279
+ }
280
+ export function writes(...tables: string[]): void {
281
+ for (const t of tables) recordTableWrite(t);
282
+ }
package/src/tag.ts CHANGED
@@ -64,20 +64,21 @@ export function tagSql(sql: string): void {
64
64
  // table API keeps tagging explicitly (precise, parser-independent); this covers
65
65
  // the raw escape hatch so `cache(() => db.query("select ... from posts"))` is
66
66
  // invalidated by a posts write instead of going silently stale.
67
+ //
68
+ // A PROXY, not a spread: the ambient `db` resource is itself a Proxy whose methods
69
+ // come from a get-trap, so `{ ...db }` would drop exec/transaction/close. The
70
+ // Proxy forwards everything to the underlying handle and only wraps query/get/run.
67
71
  export function taggingDb(db: JuneDb): JuneDb {
68
- return {
69
- ...db,
70
- query<T = unknown>(sql: string, params?: unknown[]) {
71
- tagSql(sql);
72
- return db.query<T>(sql, params);
72
+ const query = <T = unknown>(sql: string, params?: unknown[]) => (tagSql(sql), db.query<T>(sql, params));
73
+ const get = <T = unknown>(sql: string, params?: unknown[]) => (tagSql(sql), db.get<T>(sql, params));
74
+ const run = (sql: string, params?: unknown[]) => (tagSql(sql), db.run(sql, params));
75
+ return new Proxy(db, {
76
+ get(target, prop, receiver) {
77
+ if (prop === "query") return query;
78
+ if (prop === "get") return get;
79
+ if (prop === "run") return run;
80
+ const value = Reflect.get(target, prop, receiver);
81
+ return typeof value === "function" ? value.bind(target) : value;
73
82
  },
74
- get<T = unknown>(sql: string, params?: unknown[]) {
75
- tagSql(sql);
76
- return db.get<T>(sql, params);
77
- },
78
- run(sql: string, params?: unknown[]) {
79
- tagSql(sql);
80
- return db.run(sql, params);
81
- },
82
- };
83
+ });
83
84
  }