@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 +15 -0
- package/package.json +4 -4
- package/src/compiler.ts +45 -22
- package/src/index.ts +37 -7
- package/src/types.ts +64 -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.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.
|
|
20
|
-
"@junejs/db": "0.0.
|
|
19
|
+
"@junejs/core": "0.0.16",
|
|
20
|
+
"@junejs/db": "0.0.16"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@junejs/server": "0.0.
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
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 `${
|
|
29
|
+
return `${q(p.col)} in (${Array.from({ length: p.arity }, ph).join(", ")})`;
|
|
28
30
|
}
|
|
29
|
-
return `${
|
|
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) => `${
|
|
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 ${
|
|
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(
|
|
107
|
+
const cols = node.columns.map(q);
|
|
98
108
|
const vals = node.columns.map(() => this.placeholder(++i));
|
|
99
|
-
return `insert into ${
|
|
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) => `${
|
|
104
|
-
const where = node.where.map((c) => `${
|
|
105
|
-
return `update ${
|
|
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) => `${
|
|
110
|
-
return `delete from ${
|
|
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(
|
|
124
|
+
const cols = node.columns.map(q);
|
|
115
125
|
const vals = node.columns.map(() => this.placeholder(++i));
|
|
116
|
-
const set = node.update.map((c) => `${
|
|
126
|
+
const set = node.update.map((c) => `${q(c)} = excluded.${q(c)}`).join(", ");
|
|
117
127
|
return (
|
|
118
|
-
`insert into ${
|
|
119
|
-
`on conflict (${node.conflict.map(
|
|
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
|
-
//
|
|
133
|
-
//
|
|
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
|
|
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
|
-
|
|
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<
|
|
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
|
|
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
|
+
}
|