@junejs/juno 0.0.15 → 0.0.17
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 +15 -0
- package/package.json +7 -4
- package/src/batch.ts +13 -10
- package/src/compiler.ts +84 -22
- package/src/index.ts +51 -14
- package/src/types.ts +136 -0
package/README.md
CHANGED
|
@@ -34,6 +34,21 @@ await table("users").upsert({ email: "ada@x.dev", name: "Ada" }, { onConflict: "
|
|
|
34
34
|
await db.query("select * from users where age > ?", [18]); // raw escape hatch (auto-tags)
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
## Typed tables (zero config)
|
|
38
|
+
|
|
39
|
+
`table("users")` infers its row — the table name autocompletes, columns and their
|
|
40
|
+
types flow through `findBy` / `all` / `where` / `orderBy` — with **no inline generic**.
|
|
41
|
+
Run it once and after every migration:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
june db types # introspects the migrated db → writes db/schema.d.ts
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
That file is a `declare module "@junejs/juno"` augmentation generated from your SQL
|
|
48
|
+
schema (`db/migrations/`), so types can't drift from the database and you maintain no
|
|
49
|
+
schema DSL. Without it, `table()` is still fully usable — it just returns the untyped
|
|
50
|
+
`Row` (or pass an inline generic: `table<User>("users")`).
|
|
51
|
+
|
|
37
52
|
## Non-obvious facts (the easy-to-get-wrong bits)
|
|
38
53
|
|
|
39
54
|
The API shape is self-explanatory; these are the things models and humans get
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junejs/juno",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.17",
|
|
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,11 +16,14 @@
|
|
|
16
16
|
"typecheck": "tsc --noEmit"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@junejs/core": "0.0.
|
|
20
|
-
"@junejs/db": "0.0.
|
|
19
|
+
"@junejs/core": "0.0.17",
|
|
20
|
+
"@junejs/db": "0.0.17"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@junejs/server": "0.0.
|
|
23
|
+
"@junejs/server": "0.0.17",
|
|
24
|
+
"@types/pg": "^8.20.0",
|
|
25
|
+
"mysql2": "^3.22.5",
|
|
26
|
+
"pg": "^8.21.0"
|
|
24
27
|
},
|
|
25
28
|
"repository": {
|
|
26
29
|
"type": "git",
|
package/src/batch.ts
CHANGED
|
@@ -7,8 +7,19 @@
|
|
|
7
7
|
import type { JuneDb } from "@junejs/core/resources";
|
|
8
8
|
import { recordTableRead } from "@junejs/core/instrumentation";
|
|
9
9
|
|
|
10
|
+
import type { SelectNode } from "./ast";
|
|
11
|
+
import { dialectFor } from "./compiler";
|
|
10
12
|
import type { Row } from "./index";
|
|
11
13
|
|
|
14
|
+
// The batched `where <key> in (?, ?, …)` read as an operation node, compiled per
|
|
15
|
+
// batch with the db's dialect (sqlite `?` / Postgres `$n` / MySQL backticks) — so the
|
|
16
|
+
// auto-batch path is multi-dialect like the rest of the Table API. arity = batch size.
|
|
17
|
+
function inQuery<V extends Row>(db: JuneDb, table: string, key: string, keys: unknown[]): Promise<V[]> {
|
|
18
|
+
recordTableRead(table); // batched read still participates in cache auto-tagging
|
|
19
|
+
const node: SelectNode = { kind: "select", from: table, where: [{ col: key, op: "in", arity: keys.length }] };
|
|
20
|
+
return db.query<V>(dialectFor(db).compile(node), keys);
|
|
21
|
+
}
|
|
22
|
+
|
|
12
23
|
export type Loader<K, V> = {
|
|
13
24
|
load(key: K): Promise<V | null>;
|
|
14
25
|
};
|
|
@@ -63,11 +74,7 @@ export function tableLoader<V extends Row = Row>(
|
|
|
63
74
|
throw new Error(`unsafe SQL identifier: ${table}.${key}`);
|
|
64
75
|
}
|
|
65
76
|
return createLoader<string | number, V>(
|
|
66
|
-
|
|
67
|
-
recordTableRead(table); // batched read still participates in cache auto-tagging
|
|
68
|
-
const placeholders = keys.map(() => "?").join(", ");
|
|
69
|
-
return db.query<V>(`select * from ${table} where ${key} in (${placeholders})`, keys);
|
|
70
|
-
},
|
|
77
|
+
(keys) => inQuery<V>(db, table, key, keys),
|
|
71
78
|
(row) => (row as Row)[key] as string | number,
|
|
72
79
|
);
|
|
73
80
|
}
|
|
@@ -132,11 +139,7 @@ export function tableListLoader<V extends Row = Row>(
|
|
|
132
139
|
throw new Error(`unsafe SQL identifier: ${table}.${key}`);
|
|
133
140
|
}
|
|
134
141
|
return createGroupLoader<string | number, V>(
|
|
135
|
-
|
|
136
|
-
recordTableRead(table);
|
|
137
|
-
const placeholders = keys.map(() => "?").join(", ");
|
|
138
|
-
return db.query<V>(`select * from ${table} where ${key} in (${placeholders})`, keys);
|
|
139
|
-
},
|
|
142
|
+
(keys) => inQuery<V>(db, table, key, keys),
|
|
140
143
|
(row) => (row as Row)[key] as string | number,
|
|
141
144
|
);
|
|
142
145
|
}
|
package/src/compiler.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
// The dialect compiler — walks an operation-node AST (ast.ts) into a SQL string.
|
|
2
2
|
// A `Dialect` is the seam multi-dialect lands on: subclasses override placeholder
|
|
3
|
-
// style / quoting
|
|
4
|
-
// PostgresDialect (`$n` placeholders
|
|
3
|
+
// style / identifier quoting. SqliteDialect (sqlite + D1, bare `?`) is the default;
|
|
4
|
+
// PostgresDialect (`$n` placeholders, double-quoted identifiers) is the sibling. Both
|
|
5
|
+
// share the upsert / limit / returning syntax — only the two dialect bits differ.
|
|
5
6
|
//
|
|
6
7
|
// Compile-once: `compile()` memoizes by the node's structural key. Nodes hold
|
|
7
8
|
// shape only (no parameter values), so the same query shape compiles ONCE and
|
|
8
9
|
// every later call reuses the string — what Kysely-style builders don't do (they
|
|
9
10
|
// recompile per execute). Param VALUES are bound by the caller, never cached.
|
|
10
11
|
|
|
12
|
+
import type { JuneDb, SqlDialect } from "@junejs/core/resources";
|
|
13
|
+
|
|
11
14
|
import type { CmpOp, Node, Predicate } from "./ast";
|
|
12
15
|
|
|
13
16
|
const SYM: Record<CmpOp, string> = {
|
|
@@ -21,12 +24,13 @@ const SYM: Record<CmpOp, string> = {
|
|
|
21
24
|
};
|
|
22
25
|
|
|
23
26
|
// 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
|
-
|
|
27
|
+
// dialect's counter), so `in` can consume `arity` of them in order. `q()` is the
|
|
28
|
+
// dialect's identifier quoter (bare on sqlite, double-quoted on Postgres).
|
|
29
|
+
function predicate(p: Predicate, ph: () => string, q: (name: string) => string): string {
|
|
26
30
|
if (p.op === "in") {
|
|
27
|
-
return `${
|
|
31
|
+
return `${q(p.col)} in (${Array.from({ length: p.arity }, ph).join(", ")})`;
|
|
28
32
|
}
|
|
29
|
-
return `${
|
|
33
|
+
return `${q(p.col)} ${SYM[p.op]} ${ph()}`;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
// Guard table/column names — identifiers can't be parameterized. Values always go
|
|
@@ -77,46 +81,54 @@ export abstract class Dialect {
|
|
|
77
81
|
// The i-th bound placeholder (1-based). Sqlite ignores i (`?`); Postgres uses `$i`.
|
|
78
82
|
protected abstract placeholder(i: number): string;
|
|
79
83
|
|
|
84
|
+
// Identifier emission — validated, then quoted per dialect. Sqlite emits bare
|
|
85
|
+
// (`col`); Postgres double-quotes (`"col"`) so reserved words (user, order) and
|
|
86
|
+
// exact case survive. Overridable so the seam covers quoting, not just placeholders.
|
|
87
|
+
protected quoteId(name: string): string {
|
|
88
|
+
return ident(name);
|
|
89
|
+
}
|
|
90
|
+
|
|
80
91
|
protected emit(node: Node): string {
|
|
92
|
+
const q = (name: string) => this.quoteId(name);
|
|
81
93
|
switch (node.kind) {
|
|
82
94
|
case "select": {
|
|
83
95
|
let i = 0;
|
|
84
96
|
const ph = () => this.placeholder(++i);
|
|
85
97
|
const where = node.where.length
|
|
86
|
-
? ` where ${node.where.map((p) => predicate(p, ph)).join(" and ")}`
|
|
98
|
+
? ` where ${node.where.map((p) => predicate(p, ph, q)).join(" and ")}`
|
|
87
99
|
: "";
|
|
88
100
|
const order = node.orderBy?.length
|
|
89
|
-
? ` order by ${node.orderBy.map((o) => `${
|
|
101
|
+
? ` order by ${node.orderBy.map((o) => `${q(o.col)} ${o.dir === "desc" ? "desc" : "asc"}`).join(", ")}`
|
|
90
102
|
: "";
|
|
91
103
|
const limit = node.limit === "param" ? ` limit ${ph()}` : node.limit != null ? ` limit ${node.limit}` : "";
|
|
92
104
|
const offset = node.offset === "param" ? ` offset ${ph()}` : "";
|
|
93
|
-
return `select * from ${
|
|
105
|
+
return `select * from ${q(node.from)}${where}${order}${limit}${offset}`;
|
|
94
106
|
}
|
|
95
107
|
case "insert": {
|
|
96
108
|
let i = 0;
|
|
97
|
-
const cols = node.columns.map(
|
|
109
|
+
const cols = node.columns.map(q);
|
|
98
110
|
const vals = node.columns.map(() => this.placeholder(++i));
|
|
99
|
-
return `insert into ${
|
|
111
|
+
return `insert into ${q(node.into)} (${cols.join(", ")}) values (${vals.join(", ")})`;
|
|
100
112
|
}
|
|
101
113
|
case "update": {
|
|
102
114
|
let i = 0;
|
|
103
|
-
const set = node.set.map((c) => `${
|
|
104
|
-
const where = node.where.map((c) => `${
|
|
105
|
-
return `update ${
|
|
115
|
+
const set = node.set.map((c) => `${q(c)} = ${this.placeholder(++i)}`).join(", ");
|
|
116
|
+
const where = node.where.map((c) => `${q(c)} = ${this.placeholder(++i)}`).join(" and ");
|
|
117
|
+
return `update ${q(node.table)} set ${set} where ${where}`;
|
|
106
118
|
}
|
|
107
119
|
case "delete": {
|
|
108
120
|
let i = 0;
|
|
109
|
-
const where = node.where.map((c) => `${
|
|
110
|
-
return `delete from ${
|
|
121
|
+
const where = node.where.map((c) => `${q(c)} = ${this.placeholder(++i)}`).join(" and ");
|
|
122
|
+
return `delete from ${q(node.from)} where ${where}`;
|
|
111
123
|
}
|
|
112
124
|
case "upsert": {
|
|
113
125
|
let i = 0;
|
|
114
|
-
const cols = node.columns.map(
|
|
126
|
+
const cols = node.columns.map(q);
|
|
115
127
|
const vals = node.columns.map(() => this.placeholder(++i));
|
|
116
|
-
const set = node.update.map((c) => `${
|
|
128
|
+
const set = node.update.map((c) => `${q(c)} = excluded.${q(c)}`).join(", ");
|
|
117
129
|
return (
|
|
118
|
-
`insert into ${
|
|
119
|
-
`on conflict (${node.conflict.map(
|
|
130
|
+
`insert into ${q(node.into)} (${cols.join(", ")}) values (${vals.join(", ")}) ` +
|
|
131
|
+
`on conflict (${node.conflict.map(q).join(", ")}) do update set ${set} returning *`
|
|
120
132
|
);
|
|
121
133
|
}
|
|
122
134
|
}
|
|
@@ -129,6 +141,56 @@ export class SqliteDialect extends Dialect {
|
|
|
129
141
|
}
|
|
130
142
|
}
|
|
131
143
|
|
|
132
|
-
//
|
|
133
|
-
//
|
|
144
|
+
// Postgres: numbered `$n` placeholders and double-quoted identifiers (so a column
|
|
145
|
+
// named `user`/`order`, or any non-lowercase name, is emitted correctly). Same AST,
|
|
146
|
+
// same upsert/limit/returning syntax — only the two dialect-specific bits differ.
|
|
147
|
+
export class PostgresDialect extends Dialect {
|
|
148
|
+
protected placeholder(i: number): string {
|
|
149
|
+
return `$${i}`;
|
|
150
|
+
}
|
|
151
|
+
protected override quoteId(name: string): string {
|
|
152
|
+
return `"${ident(name)}"`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// MySQL: positional `?` placeholders (like sqlite) but backtick-quoted identifiers,
|
|
157
|
+
// and a different upsert — MySQL has no `ON CONFLICT`/`RETURNING`, it keys off any
|
|
158
|
+
// unique/primary index with `ON DUPLICATE KEY UPDATE` and the 8.0.19+ row alias
|
|
159
|
+
// (`AS new … new.col`). So `conflict` columns are ignored (MySQL picks the violated
|
|
160
|
+
// key) and upsert returns no row — the Table layer must re-read if it needs one.
|
|
161
|
+
export class MysqlDialect extends Dialect {
|
|
162
|
+
protected placeholder(): string {
|
|
163
|
+
return "?";
|
|
164
|
+
}
|
|
165
|
+
protected override quoteId(name: string): string {
|
|
166
|
+
return `\`${ident(name)}\``;
|
|
167
|
+
}
|
|
168
|
+
protected override emit(node: Node): string {
|
|
169
|
+
if (node.kind !== "upsert") return super.emit(node);
|
|
170
|
+
const q = (name: string) => this.quoteId(name);
|
|
171
|
+
const cols = node.columns.map(q);
|
|
172
|
+
const vals = node.columns.map(() => this.placeholder()); // `?` is positional — no index
|
|
173
|
+
const set = node.update.map((c) => `${q(c)} = new.${q(c)}`).join(", ");
|
|
174
|
+
return (
|
|
175
|
+
`insert into ${q(node.into)} (${cols.join(", ")}) values (${vals.join(", ")}) as new ` +
|
|
176
|
+
`on duplicate key update ${set}`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Shared singletons so each dialect's compile-once cache is process-level. `sqlite`
|
|
182
|
+
// (sqlite + D1) is Juno's default; `postgres` / `mysql` compile the same nodes for PG / MySQL.
|
|
134
183
|
export const sqlite = new SqliteDialect();
|
|
184
|
+
export const postgres = new PostgresDialect();
|
|
185
|
+
export const mysql = new MysqlDialect();
|
|
186
|
+
|
|
187
|
+
// The dialect-tag → compiler registry. This is the keystone of multi-dialect runtime:
|
|
188
|
+
// a JuneDb handle declares the SQL it speaks (`db.dialect`), and Juno resolves the
|
|
189
|
+
// matching compiler here — without the framework ever knowing about dialects.
|
|
190
|
+
const BY_DIALECT: Record<SqlDialect, Dialect> = { sqlite, postgres, mysql };
|
|
191
|
+
|
|
192
|
+
// Resolve the compiler for a JuneDb handle. An untagged handle (an older driver, or
|
|
193
|
+
// the contract's optional `dialect`) defaults to sqlite — the safe, edge-first floor.
|
|
194
|
+
export function dialectFor(db: Pick<JuneDb, "dialect">): Dialect {
|
|
195
|
+
return BY_DIALECT[db.dialect ?? "sqlite"];
|
|
196
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -16,16 +16,34 @@ import { db as ambientDb, requestLocal, registerSqlTagger } from "@junejs/db";
|
|
|
16
16
|
|
|
17
17
|
import type { Predicate, SelectNode } from "./ast";
|
|
18
18
|
import { tableLoader, tableListLoader, type Loader, type ListLoader } from "./batch";
|
|
19
|
-
import {
|
|
19
|
+
import { dialectFor, type Dialect } from "./compiler";
|
|
20
20
|
import { taggingDb, tagSql } from "./tag";
|
|
21
|
+
import { emitSchemaTypes } from "./types";
|
|
21
22
|
|
|
22
23
|
export { createLoader, createGroupLoader, tableLoader, tableListLoader, type Loader, type ListLoader } from "./batch";
|
|
23
24
|
export { tablesFromSql, tagSql, taggingDb, type SqlTouch } from "./tag";
|
|
24
|
-
export { Dialect, SqliteDialect, sqlite, ident } from "./compiler";
|
|
25
|
+
export { Dialect, SqliteDialect, PostgresDialect, MysqlDialect, sqlite, postgres, mysql, dialectFor, ident } from "./compiler";
|
|
26
|
+
export { emitSchemaTypes } from "./types";
|
|
25
27
|
export type { Node, SelectNode, InsertNode, UpdateNode, DeleteNode, UpsertNode } from "./ast";
|
|
26
28
|
|
|
27
29
|
export type Row = Record<string, unknown>;
|
|
28
30
|
|
|
31
|
+
// The app's table → row-type registry (Stage 3). EMPTY by default, so `table(name)`
|
|
32
|
+
// stays untyped and every existing call keeps working (back-compat). Apps augment it
|
|
33
|
+
// via `declare module "@junejs/juno" { interface Schema { users: {...} } }` — a file
|
|
34
|
+
// `june db types` generates by introspecting the live schema (Stage 3b). Once
|
|
35
|
+
// augmented, `table("users")` autocompletes the name and infers the row, with no
|
|
36
|
+
// inline generic and no handle. Kysely-style declaration merging: zero runtime, and
|
|
37
|
+
// the schema of record stays in SQL (db/migrations/), never owned by Juno.
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
39
|
+
export interface Schema {}
|
|
40
|
+
|
|
41
|
+
// A known table name resolves to its declared row (intersected with Row so it always
|
|
42
|
+
// satisfies the Table constraint); falls back to `never` when Schema is empty, which
|
|
43
|
+
// makes the typed overload simply not match and the string overload take over.
|
|
44
|
+
export type TableNames = keyof Schema & string;
|
|
45
|
+
export type RowOf<K extends TableNames> = Schema[K] & Row;
|
|
46
|
+
|
|
29
47
|
// A WHERE value is either an equality value or an operator object (Stage 2).
|
|
30
48
|
// `{ age: { gte: 18 }, name: { like: "%a%" }, id: { in: [1, 2] } }` — AND-joined.
|
|
31
49
|
export type Operators = {
|
|
@@ -59,6 +77,13 @@ export class Table<T extends Row = Row> {
|
|
|
59
77
|
private readonly loaders: Map<string, unknown> = new Map(),
|
|
60
78
|
) {}
|
|
61
79
|
|
|
80
|
+
// The compiler for THIS table's db — picked from the handle's dialect tag (sqlite
|
|
81
|
+
// by default). So `table()` emits the right SQL whether db is sqlite/D1, Postgres,
|
|
82
|
+
// or MySQL, off the same operation-node AST. Cheap map lookup; called per query.
|
|
83
|
+
private get compiler(): Dialect {
|
|
84
|
+
return dialectFor(this.db);
|
|
85
|
+
}
|
|
86
|
+
|
|
62
87
|
// No `where` → every row. With a `where`, the list of rows matching it (the list
|
|
63
88
|
// counterpart of findBy, same where syntax). A single-column filter ambient-
|
|
64
89
|
// batches like findBy — concurrent `all({user_id})` across components coalesce
|
|
@@ -113,7 +138,7 @@ export class Table<T extends Row = Row> {
|
|
|
113
138
|
};
|
|
114
139
|
if (opts?.limit != null) params.push(opts.limit);
|
|
115
140
|
if (opts?.offset != null) params.push(opts.offset);
|
|
116
|
-
return this.db.query<T>(
|
|
141
|
+
return this.db.query<T>(this.compiler.compile(node), params);
|
|
117
142
|
}
|
|
118
143
|
|
|
119
144
|
async findBy(where: Partial<T>): Promise<T | undefined> {
|
|
@@ -130,7 +155,7 @@ export class Table<T extends Row = Row> {
|
|
|
130
155
|
return (hit ?? undefined) as T | undefined;
|
|
131
156
|
}
|
|
132
157
|
const where2: Predicate[] = keys.map((k) => ({ col: k, op: "eq" }));
|
|
133
|
-
const sql =
|
|
158
|
+
const sql = this.compiler.compile({ kind: "select", from: this.name, where: where2, limit: 1 });
|
|
134
159
|
return this.db.get<T>(sql, Object.values(where));
|
|
135
160
|
}
|
|
136
161
|
|
|
@@ -160,7 +185,7 @@ export class Table<T extends Row = Row> {
|
|
|
160
185
|
|
|
161
186
|
async insert(values: Partial<T>): Promise<RunResult> {
|
|
162
187
|
recordTableWrite(this.name); // auto-invalidate: invokeAction drops table:<name>
|
|
163
|
-
const sql =
|
|
188
|
+
const sql = this.compiler.compile({ kind: "insert", into: this.name, columns: Object.keys(values) });
|
|
164
189
|
return this.db.run(sql, Object.values(values));
|
|
165
190
|
}
|
|
166
191
|
|
|
@@ -176,13 +201,13 @@ export class Table<T extends Row = Row> {
|
|
|
176
201
|
const conflict = Array.isArray(opts.onConflict) ? opts.onConflict : [opts.onConflict];
|
|
177
202
|
const updates = columns.filter((c) => !conflict.includes(c));
|
|
178
203
|
const update = updates.length ? updates : conflict;
|
|
179
|
-
const sql =
|
|
204
|
+
const sql = this.compiler.compile({ kind: "upsert", into: this.name, columns, conflict, update });
|
|
180
205
|
return this.db.get<T>(sql, Object.values(values));
|
|
181
206
|
}
|
|
182
207
|
|
|
183
208
|
async update(where: Partial<T>, values: Partial<T>): Promise<RunResult> {
|
|
184
209
|
recordTableWrite(this.name);
|
|
185
|
-
const sql =
|
|
210
|
+
const sql = this.compiler.compile({
|
|
186
211
|
kind: "update",
|
|
187
212
|
table: this.name,
|
|
188
213
|
set: Object.keys(values),
|
|
@@ -193,7 +218,7 @@ export class Table<T extends Row = Row> {
|
|
|
193
218
|
|
|
194
219
|
async delete(where: Partial<T>): Promise<RunResult> {
|
|
195
220
|
recordTableWrite(this.name);
|
|
196
|
-
const sql =
|
|
221
|
+
const sql = this.compiler.compile({ kind: "delete", from: this.name, where: Object.keys(where) });
|
|
197
222
|
return this.db.run(sql, Object.values(where));
|
|
198
223
|
}
|
|
199
224
|
|
|
@@ -206,6 +231,10 @@ export class Table<T extends Row = Row> {
|
|
|
206
231
|
}
|
|
207
232
|
|
|
208
233
|
export type Juno = {
|
|
234
|
+
// Schema-aware: a declared table name infers its row (and autocompletes). The
|
|
235
|
+
// string overload is the fallback for dynamic names / an empty Schema / an
|
|
236
|
+
// explicit inline generic (`table<Custom>("x")`).
|
|
237
|
+
table<K extends TableNames>(name: K): Table<RowOf<K>>;
|
|
209
238
|
table<T extends Row = Row>(name: string): Table<T>;
|
|
210
239
|
// Raw escape hatch. Reads/writes through it auto-tag by parsed table name, so a
|
|
211
240
|
// `cache(() => db.query("select ... from posts"))` is invalidated by a posts
|
|
@@ -225,13 +254,16 @@ export type Juno = {
|
|
|
225
254
|
// is what keeps keys from leaking across requests.
|
|
226
255
|
export function juno(db: JuneDb): Juno {
|
|
227
256
|
const loaders = new Map<string, unknown>();
|
|
257
|
+
function table<K extends TableNames>(name: K): Table<RowOf<K>>;
|
|
258
|
+
function table<T extends Row = Row>(name: string): Table<T>;
|
|
259
|
+
function table(name: string): Table {
|
|
260
|
+
return new Table(db, name, loaders);
|
|
261
|
+
}
|
|
228
262
|
return {
|
|
229
263
|
// Table API uses the raw db and tags explicitly (precise, parser-independent);
|
|
230
264
|
// the exposed handle wraps it so the raw escape hatch auto-tags too.
|
|
231
265
|
db: taggingDb(db),
|
|
232
|
-
table
|
|
233
|
-
return new Table<T>(db, name, loaders);
|
|
234
|
-
},
|
|
266
|
+
table,
|
|
235
267
|
reads(...tables: string[]) {
|
|
236
268
|
for (const t of tables) recordTableRead(t);
|
|
237
269
|
},
|
|
@@ -260,12 +292,17 @@ export function installDataLayer(): void {
|
|
|
260
292
|
// the worker (via `module`). Explicit (config-declared), NOT an import-time global
|
|
261
293
|
// side-effect — and the framework still never imports Juno (the user's config does).
|
|
262
294
|
export function junoDataLayer(): DataLayer {
|
|
263
|
-
|
|
295
|
+
// emitTypes: `june db types` introspects the migrated db through this hook and
|
|
296
|
+
// writes db/schema.d.ts — the framework calls it via the DataLayer seam, still
|
|
297
|
+
// never importing Juno. See src/types.ts.
|
|
298
|
+
return { install: installDataLayer, module: "@junejs/juno", emitTypes: emitSchemaTypes };
|
|
264
299
|
}
|
|
265
300
|
|
|
266
|
-
export function table<
|
|
301
|
+
export function table<K extends TableNames>(name: K): Table<RowOf<K>>;
|
|
302
|
+
export function table<T extends Row = Row>(name: string): Table<T>;
|
|
303
|
+
export function table(name: string): Table {
|
|
267
304
|
const loaders = requestLocal(LOADERS, () => new Map<string, unknown>());
|
|
268
|
-
return new Table
|
|
305
|
+
return new Table(ambientDb, name, loaders);
|
|
269
306
|
}
|
|
270
307
|
|
|
271
308
|
// No separate ambient `db` export: the canonical `db` from `@junejs/db` (and
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Stage 3b / step 4 — schema codegen. Introspect a migrated database and emit the
|
|
2
|
+
// `declare module "@junejs/juno"` augmentation that powers Stage 3a's `table()`
|
|
3
|
+
// inference. `june db types` writes the result to db/schema.d.ts. The schema of record
|
|
4
|
+
// stays in SQL (db/migrations/); this only DERIVES types from it, so the two never
|
|
5
|
+
// drift. Multi-dialect: sqlite/D1 via PRAGMA, Postgres/MySQL via information_schema —
|
|
6
|
+
// dispatched on the JuneDb's `dialect` tag.
|
|
7
|
+
|
|
8
|
+
import type { JuneDb } from "@junejs/core/resources";
|
|
9
|
+
|
|
10
|
+
type Field = { name: string; ts: string };
|
|
11
|
+
|
|
12
|
+
// Bare identifier if it's a safe JS property name, else a quoted key.
|
|
13
|
+
function propKey(name: string): string {
|
|
14
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const nul = (ts: string, nullable: boolean) => (nullable ? `${ts} | null` : ts);
|
|
18
|
+
|
|
19
|
+
// --- SQLite / D1 — PRAGMA + type affinity -------------------------------------
|
|
20
|
+
type SqliteCol = { name: string; type: string; nn: number; pk: number };
|
|
21
|
+
|
|
22
|
+
function sqliteType(declared: string, notnull: number, pk: number): string {
|
|
23
|
+
const t = declared.toUpperCase();
|
|
24
|
+
let base: string;
|
|
25
|
+
if (t.includes("INT")) base = "number"; // INTEGER affinity
|
|
26
|
+
else if (t.includes("CHAR") || t.includes("CLOB") || t.includes("TEXT")) base = "string"; // TEXT
|
|
27
|
+
else if (t === "" || t.includes("BLOB")) base = "Uint8Array"; // BLOB
|
|
28
|
+
else if (t.includes("REAL") || t.includes("FLOA") || t.includes("DOUB")) base = "number"; // REAL
|
|
29
|
+
else base = "number"; // NUMERIC (decimal / boolean / date stored as 0/1/epoch)
|
|
30
|
+
// Nullable unless declared NOT NULL or part of the PK (an INTEGER PRIMARY KEY shows
|
|
31
|
+
// notnull=0 but is the never-null rowid alias).
|
|
32
|
+
return nul(base, notnull === 0 && pk === 0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function introspectSqlite(db: JuneDb): Promise<[string, Field[]][]> {
|
|
36
|
+
const tables = await db.query<{ name: string }>(
|
|
37
|
+
"select name from sqlite_master where type = 'table' " +
|
|
38
|
+
"and name not like 'sqlite_%' and name <> '_june_migrations' order by name",
|
|
39
|
+
);
|
|
40
|
+
const out: [string, Field[]][] = [];
|
|
41
|
+
for (const { name } of tables) {
|
|
42
|
+
// pragma_table_info(?) — table-valued form takes a BOUND param (injection-safe).
|
|
43
|
+
const cols = await db.query<SqliteCol>('select name, type, "notnull" as nn, pk from pragma_table_info(?)', [name]);
|
|
44
|
+
out.push([name, cols.map((c) => ({ name: c.name, ts: sqliteType(c.type, c.nn, c.pk) }))]);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- information_schema (Postgres / MySQL) ------------------------------------
|
|
50
|
+
type InfoCol = { name: string; type: string; nullable: string };
|
|
51
|
+
|
|
52
|
+
// Postgres data_type → TS, matching what node-postgres actually returns: timestamps/
|
|
53
|
+
// dates parse to Date, bigint/numeric come back as string (precision), bytea as bytes.
|
|
54
|
+
function postgresType(dataType: string, nullable: boolean): string {
|
|
55
|
+
const t = dataType.toLowerCase();
|
|
56
|
+
let base: string;
|
|
57
|
+
if (t === "boolean") base = "boolean";
|
|
58
|
+
else if (t === "smallint" || t === "integer" || t === "real" || t === "double precision") base = "number";
|
|
59
|
+
else if (t === "bigint" || t === "numeric" || t === "decimal") base = "string";
|
|
60
|
+
else if (t === "bytea") base = "Uint8Array";
|
|
61
|
+
else if (t === "json" || t === "jsonb") base = "unknown";
|
|
62
|
+
else if (t === "date" || t.startsWith("timestamp")) base = "Date";
|
|
63
|
+
else if (t === "uuid" || t === "text" || t.startsWith("character") || t === "name" || t.startsWith("time")) base = "string";
|
|
64
|
+
else base = "unknown";
|
|
65
|
+
return nul(base, nullable);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// MySQL data_type → TS, matching mysql2's defaults: DATE/DATETIME/TIMESTAMP → Date,
|
|
69
|
+
// DECIMAL → string, BLOB/BINARY → bytes, JSON parsed (unknown).
|
|
70
|
+
function mysqlType(dataType: string, nullable: boolean): string {
|
|
71
|
+
const t = dataType.toLowerCase();
|
|
72
|
+
let base: string;
|
|
73
|
+
if (["tinyint", "smallint", "mediumint", "int", "integer", "bigint", "float", "double", "year"].includes(t)) base = "number";
|
|
74
|
+
else if (t === "decimal" || t === "numeric") base = "string";
|
|
75
|
+
else if (["char", "varchar", "text", "tinytext", "mediumtext", "longtext", "enum", "set", "time"].includes(t)) base = "string";
|
|
76
|
+
else if (t === "date" || t === "datetime" || t === "timestamp") base = "Date";
|
|
77
|
+
else if (t === "json") base = "unknown";
|
|
78
|
+
else if (t.includes("blob") || t.includes("binary") || t === "bit") base = "Uint8Array";
|
|
79
|
+
else base = "unknown";
|
|
80
|
+
return nul(base, nullable);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Generic information_schema walk; `ph` is the dialect's placeholder, `scope` the SQL
|
|
84
|
+
// expression for the current schema (current_schema() / database()), `map` the type map.
|
|
85
|
+
async function introspectInfoSchema(
|
|
86
|
+
db: JuneDb,
|
|
87
|
+
ph: string,
|
|
88
|
+
scope: string,
|
|
89
|
+
map: (dataType: string, nullable: boolean) => string,
|
|
90
|
+
): Promise<[string, Field[]][]> {
|
|
91
|
+
const tables = await db.query<{ name: string }>(
|
|
92
|
+
`select table_name as name from information_schema.tables ` +
|
|
93
|
+
`where table_schema = ${scope} and table_type = 'BASE TABLE' ` +
|
|
94
|
+
`and table_name <> '_june_migrations' order by table_name`,
|
|
95
|
+
);
|
|
96
|
+
const out: [string, Field[]][] = [];
|
|
97
|
+
for (const { name } of tables) {
|
|
98
|
+
const cols = await db.query<InfoCol>(
|
|
99
|
+
`select column_name as name, data_type as type, is_nullable as nullable ` +
|
|
100
|
+
`from information_schema.columns where table_schema = ${scope} and table_name = ${ph} ` +
|
|
101
|
+
`order by ordinal_position`,
|
|
102
|
+
[name],
|
|
103
|
+
);
|
|
104
|
+
out.push([name, cols.map((c) => ({ name: c.name, ts: map(c.type, c.nullable === "YES") }))]);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- assembly + dispatch ------------------------------------------------------
|
|
110
|
+
function assemble(tables: [string, Field[]][]): string {
|
|
111
|
+
const ifaces = tables.map(([name, fields]) => {
|
|
112
|
+
const lines = fields.map((f) => ` ${propKey(f.name)}: ${f.ts};`).join("\n");
|
|
113
|
+
return ` ${propKey(name)}: {\n${lines}\n };`;
|
|
114
|
+
});
|
|
115
|
+
return (
|
|
116
|
+
"// Generated by `june db types`. Do not edit — re-run after a migration.\n" +
|
|
117
|
+
'import "@junejs/juno";\n\n' +
|
|
118
|
+
'declare module "@junejs/juno" {\n' +
|
|
119
|
+
" interface Schema {\n" +
|
|
120
|
+
ifaces.join("\n") +
|
|
121
|
+
"\n }\n}\n"
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Introspect every user table (skipping the migration ledger + engine internals) and
|
|
126
|
+
// emit the augmentation text. Reads only — never mutates the database.
|
|
127
|
+
export async function emitSchemaTypes(db: JuneDb): Promise<string> {
|
|
128
|
+
const dialect = db.dialect ?? "sqlite";
|
|
129
|
+
const tables =
|
|
130
|
+
dialect === "postgres"
|
|
131
|
+
? await introspectInfoSchema(db, "$1", "current_schema()", postgresType)
|
|
132
|
+
: dialect === "mysql"
|
|
133
|
+
? await introspectInfoSchema(db, "?", "database()", mysqlType)
|
|
134
|
+
: await introspectSqlite(db);
|
|
135
|
+
return assemble(tables);
|
|
136
|
+
}
|