@junejs/juno 0.0.16 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/juno",
3
- "version": "0.0.16",
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.16",
20
- "@junejs/db": "0.0.16"
19
+ "@junejs/core": "0.0.17",
20
+ "@junejs/db": "0.0.17"
21
21
  },
22
22
  "devDependencies": {
23
- "@junejs/server": "0.0.16"
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
@@ -9,6 +9,8 @@
9
9
  // every later call reuses the string — what Kysely-style builders don't do (they
10
10
  // recompile per execute). Param VALUES are bound by the caller, never cached.
11
11
 
12
+ import type { JuneDb, SqlDialect } from "@junejs/core/resources";
13
+
12
14
  import type { CmpOp, Node, Predicate } from "./ast";
13
15
 
14
16
  const SYM: Record<CmpOp, string> = {
@@ -151,7 +153,44 @@ export class PostgresDialect extends Dialect {
151
153
  }
152
154
  }
153
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
+
154
181
  // Shared singletons so each dialect's compile-once cache is process-level. `sqlite`
155
- // (sqlite + D1) is Juno's default; `postgres` compiles the same nodes for PG.
182
+ // (sqlite + D1) is Juno's default; `postgres` / `mysql` compile the same nodes for PG / MySQL.
156
183
  export const sqlite = new SqliteDialect();
157
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,13 +16,13 @@ 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
21
  import { emitSchemaTypes } from "./types";
22
22
 
23
23
  export { createLoader, createGroupLoader, tableLoader, tableListLoader, type Loader, type ListLoader } from "./batch";
24
24
  export { tablesFromSql, tagSql, taggingDb, type SqlTouch } from "./tag";
25
- export { Dialect, SqliteDialect, PostgresDialect, sqlite, postgres, ident } from "./compiler";
25
+ export { Dialect, SqliteDialect, PostgresDialect, MysqlDialect, sqlite, postgres, mysql, dialectFor, ident } from "./compiler";
26
26
  export { emitSchemaTypes } from "./types";
27
27
  export type { Node, SelectNode, InsertNode, UpdateNode, DeleteNode, UpsertNode } from "./ast";
28
28
 
@@ -77,6 +77,13 @@ export class Table<T extends Row = Row> {
77
77
  private readonly loaders: Map<string, unknown> = new Map(),
78
78
  ) {}
79
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
+
80
87
  // No `where` → every row. With a `where`, the list of rows matching it (the list
81
88
  // counterpart of findBy, same where syntax). A single-column filter ambient-
82
89
  // batches like findBy — concurrent `all({user_id})` across components coalesce
@@ -131,7 +138,7 @@ export class Table<T extends Row = Row> {
131
138
  };
132
139
  if (opts?.limit != null) params.push(opts.limit);
133
140
  if (opts?.offset != null) params.push(opts.offset);
134
- return this.db.query<T>(sqlite.compile(node), params);
141
+ return this.db.query<T>(this.compiler.compile(node), params);
135
142
  }
136
143
 
137
144
  async findBy(where: Partial<T>): Promise<T | undefined> {
@@ -148,7 +155,7 @@ export class Table<T extends Row = Row> {
148
155
  return (hit ?? undefined) as T | undefined;
149
156
  }
150
157
  const where2: Predicate[] = keys.map((k) => ({ col: k, op: "eq" }));
151
- 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 });
152
159
  return this.db.get<T>(sql, Object.values(where));
153
160
  }
154
161
 
@@ -178,7 +185,7 @@ export class Table<T extends Row = Row> {
178
185
 
179
186
  async insert(values: Partial<T>): Promise<RunResult> {
180
187
  recordTableWrite(this.name); // auto-invalidate: invokeAction drops table:<name>
181
- 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) });
182
189
  return this.db.run(sql, Object.values(values));
183
190
  }
184
191
 
@@ -194,13 +201,13 @@ export class Table<T extends Row = Row> {
194
201
  const conflict = Array.isArray(opts.onConflict) ? opts.onConflict : [opts.onConflict];
195
202
  const updates = columns.filter((c) => !conflict.includes(c));
196
203
  const update = updates.length ? updates : conflict;
197
- 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 });
198
205
  return this.db.get<T>(sql, Object.values(values));
199
206
  }
200
207
 
201
208
  async update(where: Partial<T>, values: Partial<T>): Promise<RunResult> {
202
209
  recordTableWrite(this.name);
203
- const sql = sqlite.compile({
210
+ const sql = this.compiler.compile({
204
211
  kind: "update",
205
212
  table: this.name,
206
213
  set: Object.keys(values),
@@ -211,7 +218,7 @@ export class Table<T extends Row = Row> {
211
218
 
212
219
  async delete(where: Partial<T>): Promise<RunResult> {
213
220
  recordTableWrite(this.name);
214
- 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) });
215
222
  return this.db.run(sql, Object.values(where));
216
223
  }
217
224
 
package/src/types.ts CHANGED
@@ -1,15 +1,25 @@
1
- // Stage 3b — schema codegen. Introspect a migrated SQLite/D1 database and emit the
1
+ // Stage 3b / step 4 — schema codegen. Introspect a migrated database and emit the
2
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
4
- // record stays in SQL (db/migrations/); this only DERIVES types from it, so the two
5
- // never drift. Pure introspection — no schema DSL, nothing for the app to maintain.
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.
6
7
 
7
8
  import type { JuneDb } from "@junejs/core/resources";
8
9
 
9
- // SQLite type affinity → TypeScript. Mirrors SQLite's affinity rules (the same five
10
- // the engine itself applies), so a declared `varchar(40)` lands on `string`, `int`
11
- // on `number`, etc. Unknown/blank affinity → Uint8Array (BLOB), the safe default.
12
- function tsType(declared: string, notnull: number, pk: number): string {
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 {
13
23
  const t = declared.toUpperCase();
14
24
  let base: string;
15
25
  if (t.includes("INT")) base = "number"; // INTEGER affinity
@@ -17,42 +27,91 @@ function tsType(declared: string, notnull: number, pk: number): string {
17
27
  else if (t === "" || t.includes("BLOB")) base = "Uint8Array"; // BLOB
18
28
  else if (t.includes("REAL") || t.includes("FLOA") || t.includes("DOUB")) base = "number"; // REAL
19
29
  else base = "number"; // NUMERIC (decimal / boolean / date stored as 0/1/epoch)
20
- // A column is nullable unless declared NOT NULL or part of the primary key (an
21
- // INTEGER PRIMARY KEY reports notnull=0 but is the never-null rowid alias).
22
- const nullable = notnull === 0 && pk === 0;
23
- return nullable ? `${base} | null` : base;
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);
24
33
  }
25
34
 
26
- // Bare identifier if it's a safe JS property name, else a quoted key.
27
- function propKey(name: string): string {
28
- return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
29
- }
30
-
31
- type TableRow = { name: string };
32
- type ColRow = { name: string; type: string; nn: number; pk: number };
33
-
34
- // Introspect every user table (skipping sqlite internals + June's migration ledger)
35
- // and return the augmentation file text. Reads only — never mutates the database.
36
- export async function emitSchemaTypes(db: JuneDb): Promise<string> {
37
- const tables = await db.query<TableRow>(
35
+ async function introspectSqlite(db: JuneDb): Promise<[string, Field[]][]> {
36
+ const tables = await db.query<{ name: string }>(
38
37
  "select name from sqlite_master where type = 'table' " +
39
38
  "and name not like 'sqlite_%' and name <> '_june_migrations' order by name",
40
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 };
41
51
 
42
- const ifaces: string[] = [];
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[]][] = [];
43
97
  for (const { name } of tables) {
44
- // pragma_table_info(?) — the table-valued form takes a BOUND param, so no
45
- // identifier interpolation (injection-safe even for odd table names).
46
- const cols = await db.query<ColRow>(
47
- 'select name, type, "notnull" as nn, pk from pragma_table_info(?)',
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`,
48
102
  [name],
49
103
  );
50
- const fields = cols
51
- .map((c) => ` ${propKey(c.name)}: ${tsType(c.type, c.nn, c.pk)};`)
52
- .join("\n");
53
- ifaces.push(` ${propKey(name)}: {\n${fields}\n };`);
104
+ out.push([name, cols.map((c) => ({ name: c.name, ts: map(c.type, c.nullable === "YES") }))]);
54
105
  }
106
+ return out;
107
+ }
55
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
+ });
56
115
  return (
57
116
  "// Generated by `june db types`. Do not edit — re-run after a migration.\n" +
58
117
  'import "@junejs/juno";\n\n' +
@@ -62,3 +121,16 @@ export async function emitSchemaTypes(db: JuneDb): Promise<string> {
62
121
  "\n }\n}\n"
63
122
  );
64
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
+ }