@junejs/juno 0.0.14 → 0.0.16

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.14",
3
+ "version": "0.0.16",
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,11 @@
16
16
  "typecheck": "tsc --noEmit"
17
17
  },
18
18
  "dependencies": {
19
- "@junejs/core": "0.0.14",
20
- "@junejs/db": "0.0.14"
19
+ "@junejs/core": "0.0.16",
20
+ "@junejs/db": "0.0.16"
21
21
  },
22
22
  "devDependencies": {
23
- "@junejs/server": "0.0.14"
23
+ "@junejs/server": "0.0.16"
24
24
  },
25
25
  "repository": {
26
26
  "type": "git",
package/src/compiler.ts CHANGED
@@ -1,7 +1,8 @@
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
@@ -21,12 +22,13 @@ const SYM: Record<CmpOp, string> = {
21
22
  };
22
23
 
23
24
  // 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 {
25
+ // dialect's counter), so `in` can consume `arity` of them in order. `q()` is the
26
+ // dialect's identifier quoter (bare on sqlite, double-quoted on Postgres).
27
+ function predicate(p: Predicate, ph: () => string, q: (name: string) => string): string {
26
28
  if (p.op === "in") {
27
- return `${ident(p.col)} in (${Array.from({ length: p.arity }, ph).join(", ")})`;
29
+ return `${q(p.col)} in (${Array.from({ length: p.arity }, ph).join(", ")})`;
28
30
  }
29
- return `${ident(p.col)} ${SYM[p.op]} ${ph()}`;
31
+ return `${q(p.col)} ${SYM[p.op]} ${ph()}`;
30
32
  }
31
33
 
32
34
  // Guard table/column names — identifiers can't be parameterized. Values always go
@@ -77,46 +79,54 @@ export abstract class Dialect {
77
79
  // The i-th bound placeholder (1-based). Sqlite ignores i (`?`); Postgres uses `$i`.
78
80
  protected abstract placeholder(i: number): string;
79
81
 
82
+ // Identifier emission — validated, then quoted per dialect. Sqlite emits bare
83
+ // (`col`); Postgres double-quotes (`"col"`) so reserved words (user, order) and
84
+ // exact case survive. Overridable so the seam covers quoting, not just placeholders.
85
+ protected quoteId(name: string): string {
86
+ return ident(name);
87
+ }
88
+
80
89
  protected emit(node: Node): string {
90
+ const q = (name: string) => this.quoteId(name);
81
91
  switch (node.kind) {
82
92
  case "select": {
83
93
  let i = 0;
84
94
  const ph = () => this.placeholder(++i);
85
95
  const where = node.where.length
86
- ? ` where ${node.where.map((p) => predicate(p, ph)).join(" and ")}`
96
+ ? ` where ${node.where.map((p) => predicate(p, ph, q)).join(" and ")}`
87
97
  : "";
88
98
  const order = node.orderBy?.length
89
- ? ` order by ${node.orderBy.map((o) => `${ident(o.col)} ${o.dir === "desc" ? "desc" : "asc"}`).join(", ")}`
99
+ ? ` order by ${node.orderBy.map((o) => `${q(o.col)} ${o.dir === "desc" ? "desc" : "asc"}`).join(", ")}`
90
100
  : "";
91
101
  const limit = node.limit === "param" ? ` limit ${ph()}` : node.limit != null ? ` limit ${node.limit}` : "";
92
102
  const offset = node.offset === "param" ? ` offset ${ph()}` : "";
93
- return `select * from ${ident(node.from)}${where}${order}${limit}${offset}`;
103
+ return `select * from ${q(node.from)}${where}${order}${limit}${offset}`;
94
104
  }
95
105
  case "insert": {
96
106
  let i = 0;
97
- const cols = node.columns.map(ident);
107
+ const cols = node.columns.map(q);
98
108
  const vals = node.columns.map(() => this.placeholder(++i));
99
- return `insert into ${ident(node.into)} (${cols.join(", ")}) values (${vals.join(", ")})`;
109
+ return `insert into ${q(node.into)} (${cols.join(", ")}) values (${vals.join(", ")})`;
100
110
  }
101
111
  case "update": {
102
112
  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}`;
113
+ const set = node.set.map((c) => `${q(c)} = ${this.placeholder(++i)}`).join(", ");
114
+ const where = node.where.map((c) => `${q(c)} = ${this.placeholder(++i)}`).join(" and ");
115
+ return `update ${q(node.table)} set ${set} where ${where}`;
106
116
  }
107
117
  case "delete": {
108
118
  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}`;
119
+ const where = node.where.map((c) => `${q(c)} = ${this.placeholder(++i)}`).join(" and ");
120
+ return `delete from ${q(node.from)} where ${where}`;
111
121
  }
112
122
  case "upsert": {
113
123
  let i = 0;
114
- const cols = node.columns.map(ident);
124
+ const cols = node.columns.map(q);
115
125
  const vals = node.columns.map(() => this.placeholder(++i));
116
- const set = node.update.map((c) => `${ident(c)} = excluded.${ident(c)}`).join(", ");
126
+ const set = node.update.map((c) => `${q(c)} = excluded.${q(c)}`).join(", ");
117
127
  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 *`
128
+ `insert into ${q(node.into)} (${cols.join(", ")}) values (${vals.join(", ")}) ` +
129
+ `on conflict (${node.conflict.map(q).join(", ")}) do update set ${set} returning *`
120
130
  );
121
131
  }
122
132
  }
@@ -129,6 +139,19 @@ export class SqliteDialect extends Dialect {
129
139
  }
130
140
  }
131
141
 
132
- // The default dialect (sqlite + D1). A shared singleton so its compile-once cache
133
- // is process-level. Postgres adds a sibling subclass later.
142
+ // Postgres: numbered `$n` placeholders and double-quoted identifiers (so a column
143
+ // named `user`/`order`, or any non-lowercase name, is emitted correctly). Same AST,
144
+ // same upsert/limit/returning syntax — only the two dialect-specific bits differ.
145
+ export class PostgresDialect extends Dialect {
146
+ protected placeholder(i: number): string {
147
+ return `$${i}`;
148
+ }
149
+ protected override quoteId(name: string): string {
150
+ return `"${ident(name)}"`;
151
+ }
152
+ }
153
+
154
+ // 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.
134
156
  export const sqlite = new SqliteDialect();
157
+ export const postgres = new PostgresDialect();
package/src/index.ts CHANGED
@@ -18,14 +18,32 @@ import type { Predicate, SelectNode } from "./ast";
18
18
  import { tableLoader, tableListLoader, type Loader, type ListLoader } from "./batch";
19
19
  import { sqlite } 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, sqlite, postgres, 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 = {
@@ -206,6 +224,10 @@ export class Table<T extends Row = Row> {
206
224
  }
207
225
 
208
226
  export type Juno = {
227
+ // Schema-aware: a declared table name infers its row (and autocompletes). The
228
+ // string overload is the fallback for dynamic names / an empty Schema / an
229
+ // explicit inline generic (`table<Custom>("x")`).
230
+ table<K extends TableNames>(name: K): Table<RowOf<K>>;
209
231
  table<T extends Row = Row>(name: string): Table<T>;
210
232
  // Raw escape hatch. Reads/writes through it auto-tag by parsed table name, so a
211
233
  // `cache(() => db.query("select ... from posts"))` is invalidated by a posts
@@ -225,13 +247,16 @@ export type Juno = {
225
247
  // is what keeps keys from leaking across requests.
226
248
  export function juno(db: JuneDb): Juno {
227
249
  const loaders = new Map<string, unknown>();
250
+ function table<K extends TableNames>(name: K): Table<RowOf<K>>;
251
+ function table<T extends Row = Row>(name: string): Table<T>;
252
+ function table(name: string): Table {
253
+ return new Table(db, name, loaders);
254
+ }
228
255
  return {
229
256
  // Table API uses the raw db and tags explicitly (precise, parser-independent);
230
257
  // the exposed handle wraps it so the raw escape hatch auto-tags too.
231
258
  db: taggingDb(db),
232
- table<T extends Row = Row>(name: string) {
233
- return new Table<T>(db, name, loaders);
234
- },
259
+ table,
235
260
  reads(...tables: string[]) {
236
261
  for (const t of tables) recordTableRead(t);
237
262
  },
@@ -260,12 +285,17 @@ export function installDataLayer(): void {
260
285
  // the worker (via `module`). Explicit (config-declared), NOT an import-time global
261
286
  // side-effect — and the framework still never imports Juno (the user's config does).
262
287
  export function junoDataLayer(): DataLayer {
263
- return { install: installDataLayer, module: "@junejs/juno" };
288
+ // emitTypes: `june db types` introspects the migrated db through this hook and
289
+ // writes db/schema.d.ts — the framework calls it via the DataLayer seam, still
290
+ // never importing Juno. See src/types.ts.
291
+ return { install: installDataLayer, module: "@junejs/juno", emitTypes: emitSchemaTypes };
264
292
  }
265
293
 
266
- export function table<T extends Row = Row>(name: string): Table<T> {
294
+ export function table<K extends TableNames>(name: K): Table<RowOf<K>>;
295
+ export function table<T extends Row = Row>(name: string): Table<T>;
296
+ export function table(name: string): Table {
267
297
  const loaders = requestLocal(LOADERS, () => new Map<string, unknown>());
268
- return new Table<T>(ambientDb, name, loaders);
298
+ return new Table(ambientDb, name, loaders);
269
299
  }
270
300
 
271
301
  // No separate ambient `db` export: the canonical `db` from `@junejs/db` (and
package/src/types.ts ADDED
@@ -0,0 +1,64 @@
1
+ // Stage 3b — schema codegen. Introspect a migrated SQLite/D1 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
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.
6
+
7
+ import type { JuneDb } from "@junejs/core/resources";
8
+
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 {
13
+ const t = declared.toUpperCase();
14
+ let base: string;
15
+ if (t.includes("INT")) base = "number"; // INTEGER affinity
16
+ else if (t.includes("CHAR") || t.includes("CLOB") || t.includes("TEXT")) base = "string"; // TEXT
17
+ else if (t === "" || t.includes("BLOB")) base = "Uint8Array"; // BLOB
18
+ else if (t.includes("REAL") || t.includes("FLOA") || t.includes("DOUB")) base = "number"; // REAL
19
+ 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;
24
+ }
25
+
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>(
38
+ "select name from sqlite_master where type = 'table' " +
39
+ "and name not like 'sqlite_%' and name <> '_june_migrations' order by name",
40
+ );
41
+
42
+ const ifaces: string[] = [];
43
+ 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(?)',
48
+ [name],
49
+ );
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 };`);
54
+ }
55
+
56
+ return (
57
+ "// Generated by `june db types`. Do not edit — re-run after a migration.\n" +
58
+ 'import "@junejs/juno";\n\n' +
59
+ 'declare module "@junejs/juno" {\n' +
60
+ " interface Schema {\n" +
61
+ ifaces.join("\n") +
62
+ "\n }\n}\n"
63
+ );
64
+ }