@junejs/juno 0.0.13 → 0.0.15
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 +59 -18
- package/package.json +4 -3
- package/src/ast.ts +57 -0
- package/src/batch.ts +69 -0
- package/src/compiler.ts +134 -0
- package/src/index.ts +171 -31
- package/src/tag.ts +15 -14
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
|
|
4
|
-
|
|
5
|
-
|
|
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 {
|
|
25
|
+
import { table } from "@junejs/juno";
|
|
26
|
+
import { db } from "@junejs/server"; // the one canonical handle
|
|
9
27
|
|
|
10
|
-
|
|
11
|
-
await
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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.
|
|
3
|
+
"version": "0.0.15",
|
|
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.
|
|
19
|
+
"@junejs/core": "0.0.15",
|
|
20
|
+
"@junejs/db": "0.0.15"
|
|
20
21
|
},
|
|
21
22
|
"devDependencies": {
|
|
22
|
-
"@junejs/server": "0.0.
|
|
23
|
+
"@junejs/server": "0.0.15"
|
|
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
|
+
}
|
package/src/compiler.ts
ADDED
|
@@ -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 {
|
|
15
|
-
import {
|
|
14
|
+
import type { DataLayer } from "@junejs/core/config";
|
|
15
|
+
import { db as ambientDb, requestLocal, registerSqlTagger } from "@junejs/db";
|
|
16
16
|
|
|
17
|
-
|
|
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
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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.
|
|
35
|
-
//
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
58
|
-
const
|
|
59
|
-
return this.db.get<T>(
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
97
|
-
return this.db.run(
|
|
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,
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
}
|