@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 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.15",
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.15",
20
- "@junejs/db": "0.0.15"
19
+ "@junejs/core": "0.0.17",
20
+ "@junejs/db": "0.0.17"
21
21
  },
22
22
  "devDependencies": {
23
- "@junejs/server": "0.0.15"
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
- async (keys) => {
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
- 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
- },
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 / upsert syntax. SqliteDialect (sqlite + D1) is the default; a
4
- // PostgresDialect (`$n` placeholders) slots in later as another subclass.
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
- function predicate(p: Predicate, ph: () => string): string {
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 `${ident(p.col)} in (${Array.from({ length: p.arity }, ph).join(", ")})`;
31
+ return `${q(p.col)} in (${Array.from({ length: p.arity }, ph).join(", ")})`;
28
32
  }
29
- return `${ident(p.col)} ${SYM[p.op]} ${ph()}`;
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) => `${ident(o.col)} ${o.dir === "desc" ? "desc" : "asc"}`).join(", ")}`
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 ${ident(node.from)}${where}${order}${limit}${offset}`;
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(ident);
109
+ const cols = node.columns.map(q);
98
110
  const vals = node.columns.map(() => this.placeholder(++i));
99
- return `insert into ${ident(node.into)} (${cols.join(", ")}) values (${vals.join(", ")})`;
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) => `${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}`;
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) => `${ident(c)} = ${this.placeholder(++i)}`).join(" and ");
110
- return `delete from ${ident(node.from)} where ${where}`;
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(ident);
126
+ const cols = node.columns.map(q);
115
127
  const vals = node.columns.map(() => this.placeholder(++i));
116
- const set = node.update.map((c) => `${ident(c)} = excluded.${ident(c)}`).join(", ");
128
+ const set = node.update.map((c) => `${q(c)} = excluded.${q(c)}`).join(", ");
117
129
  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 *`
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
- // The default dialect (sqlite + D1). A shared singleton so its compile-once cache
133
- // is process-level. Postgres adds a sibling subclass later.
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 { sqlite } from "./compiler";
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>(sqlite.compile(node), params);
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 = sqlite.compile({ kind: "select", from: this.name, where: where2, limit: 1 });
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 = sqlite.compile({ kind: "insert", into: this.name, columns: Object.keys(values) });
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 = sqlite.compile({ kind: "upsert", into: this.name, columns, conflict, update });
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 = sqlite.compile({
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 = sqlite.compile({ kind: "delete", from: this.name, where: Object.keys(where) });
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<T extends Row = Row>(name: string) {
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
- return { install: installDataLayer, module: "@junejs/juno" };
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<T extends Row = Row>(name: string): Table<T> {
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<T>(ambientDb, name, loaders);
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
+ }