@junejs/juno 0.0.12 → 0.0.13
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 +3 -3
- package/src/index.ts +56 -6
- package/src/tag.ts +83 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junejs/juno",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
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,10 +16,10 @@
|
|
|
16
16
|
"typecheck": "tsc --noEmit"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@junejs/core": "0.0.
|
|
19
|
+
"@junejs/core": "0.0.13"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
|
-
"@junejs/server": "0.0.
|
|
22
|
+
"@junejs/server": "0.0.13"
|
|
23
23
|
},
|
|
24
24
|
"repository": {
|
|
25
25
|
"type": "git",
|
package/src/index.ts
CHANGED
|
@@ -12,8 +12,10 @@ import type { JuneDb, RunResult } from "@junejs/core/resources";
|
|
|
12
12
|
import { recordTableRead, recordTableWrite } from "@junejs/core/instrumentation";
|
|
13
13
|
|
|
14
14
|
import { tableLoader, type Loader } from "./batch";
|
|
15
|
+
import { taggingDb } from "./tag";
|
|
15
16
|
|
|
16
17
|
export { createLoader, tableLoader, type Loader } from "./batch";
|
|
18
|
+
export { tablesFromSql, tagSql, taggingDb, type SqlTouch } from "./tag";
|
|
17
19
|
|
|
18
20
|
export type Row = Record<string, unknown>;
|
|
19
21
|
|
|
@@ -28,6 +30,10 @@ export class Table<T extends Row = Row> {
|
|
|
28
30
|
constructor(
|
|
29
31
|
private readonly db: JuneDb,
|
|
30
32
|
private readonly name: string,
|
|
33
|
+
// Per-request ambient-loader registry, shared across the juno() handle's
|
|
34
|
+
// .table() calls so concurrent findBy coalesce. Optional/defaulted so a
|
|
35
|
+
// standalone `new Table(db, name)` still works (it just batches alone).
|
|
36
|
+
private readonly loaders: Map<string, Loader<string | number, Row>> = new Map(),
|
|
31
37
|
) {}
|
|
32
38
|
|
|
33
39
|
async all(): Promise<T[]> {
|
|
@@ -36,12 +42,35 @@ export class Table<T extends Row = Row> {
|
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
async findBy(where: Partial<T>): Promise<T | undefined> {
|
|
39
|
-
recordTableRead(this.name);
|
|
40
|
-
const keys = Object.keys(where)
|
|
41
|
-
|
|
45
|
+
recordTableRead(this.name); // sync, in-trace: cache() auto-tags table:<name> here
|
|
46
|
+
const keys = Object.keys(where);
|
|
47
|
+
// Ambient auto-batch: a single-column equality lookup coalesces with other
|
|
48
|
+
// concurrent findBy on the same (table, column) this request into ONE
|
|
49
|
+
// `where col in (...)` query — N+1 → 1, with no loader to manage. There is no
|
|
50
|
+
// cross-tick cache, so a read AFTER a write (a later tick) re-queries and sees
|
|
51
|
+
// the new value. Multi-column or empty `where` falls back to a direct query.
|
|
52
|
+
const [col] = keys;
|
|
53
|
+
if (keys.length === 1 && col !== undefined) {
|
|
54
|
+
const hit = await this.ambientLoader(col).load((where as Row)[col] as string | number);
|
|
55
|
+
return (hit ?? undefined) as T | undefined;
|
|
56
|
+
}
|
|
57
|
+
const cols = keys.map(ident);
|
|
58
|
+
const clause = cols.length ? ` where ${cols.map((k) => `${k} = ?`).join(" and ")}` : "";
|
|
42
59
|
return this.db.get<T>(`select * from ${ident(this.name)}${clause} limit 1`, Object.values(where));
|
|
43
60
|
}
|
|
44
61
|
|
|
62
|
+
// get-or-create the per-request ambient loader for (table, column). tableLoader
|
|
63
|
+
// validates both identifiers and emits recordTableRead in its batch.
|
|
64
|
+
private ambientLoader(col: string): Loader<string | number, T> {
|
|
65
|
+
const key = `${this.name}::${col}`;
|
|
66
|
+
let loader = this.loaders.get(key);
|
|
67
|
+
if (!loader) {
|
|
68
|
+
loader = tableLoader<Row>(this.db, this.name, col);
|
|
69
|
+
this.loaders.set(key, loader);
|
|
70
|
+
}
|
|
71
|
+
return loader as Loader<string | number, T>;
|
|
72
|
+
}
|
|
73
|
+
|
|
45
74
|
async insert(values: Partial<T>): Promise<RunResult> {
|
|
46
75
|
recordTableWrite(this.name); // auto-invalidate: invokeAction drops table:<name>
|
|
47
76
|
const cols = Object.keys(values).map(ident);
|
|
@@ -78,15 +107,36 @@ export class Table<T extends Row = Row> {
|
|
|
78
107
|
|
|
79
108
|
export type Juno = {
|
|
80
109
|
table<T extends Row = Row>(name: string): Table<T>;
|
|
110
|
+
// Raw escape hatch. Reads/writes through it auto-tag by parsed table name, so a
|
|
111
|
+
// `cache(() => db.query("select ... from posts"))` is invalidated by a posts
|
|
112
|
+
// write instead of going silently stale.
|
|
81
113
|
db: JuneDb;
|
|
114
|
+
// Explicit tag escape hatch for SQL the parser can't classify (CTEs over a
|
|
115
|
+
// function, dynamic table names, a read computed outside SQL). `reads()` makes a
|
|
116
|
+
// cache() pick up the tag; `writes()` invalidates it. Explicit, so it can't
|
|
117
|
+
// silently break — the failure mode the implicit-only design had.
|
|
118
|
+
reads(...tables: string[]): void;
|
|
119
|
+
writes(...tables: string[]): void;
|
|
82
120
|
};
|
|
83
121
|
|
|
84
|
-
// Wrap any JuneDb handle (ctx.db) in Juno's ergonomic surface.
|
|
122
|
+
// Wrap any JuneDb handle (ctx.db) in Juno's ergonomic surface. Build ONE per
|
|
123
|
+
// request: the ambient-loader registry it carries is what makes concurrent
|
|
124
|
+
// findBy across components coalesce — and scoping it to the per-request handle
|
|
125
|
+
// is what keeps keys from leaking across requests.
|
|
85
126
|
export function juno(db: JuneDb): Juno {
|
|
127
|
+
const loaders = new Map<string, Loader<string | number, Row>>();
|
|
86
128
|
return {
|
|
87
|
-
db,
|
|
129
|
+
// Table API uses the raw db and tags explicitly (precise, parser-independent);
|
|
130
|
+
// the exposed handle wraps it so the raw escape hatch auto-tags too.
|
|
131
|
+
db: taggingDb(db),
|
|
88
132
|
table<T extends Row = Row>(name: string) {
|
|
89
|
-
return new Table<T>(db, name);
|
|
133
|
+
return new Table<T>(db, name, loaders);
|
|
134
|
+
},
|
|
135
|
+
reads(...tables: string[]) {
|
|
136
|
+
for (const t of tables) recordTableRead(t);
|
|
137
|
+
},
|
|
138
|
+
writes(...tables: string[]) {
|
|
139
|
+
for (const t of tables) recordTableWrite(t);
|
|
90
140
|
},
|
|
91
141
|
};
|
|
92
142
|
}
|
package/src/tag.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Best-effort extraction of the table(s) a SQL statement touches, so RAW queries
|
|
2
|
+
// through the juno handle participate in the same auto-tag / auto-invalidate as
|
|
3
|
+
// the typed table API — closing the "raw query inside cache() is silently stale"
|
|
4
|
+
// gap (positioning Appendix 3). Heuristic by design: it covers single-table reads,
|
|
5
|
+
// JOINs, and the three write verbs. Anything it can't classify falls back to the
|
|
6
|
+
// explicit j.reads()/j.writes() escape hatch, so the failure mode is "developer
|
|
7
|
+
// tags it" — never silent staleness slipping past a correct-looking call.
|
|
8
|
+
|
|
9
|
+
import { recordTableRead, recordTableWrite } from "@junejs/core/instrumentation";
|
|
10
|
+
import type { JuneDb } from "@junejs/core/resources";
|
|
11
|
+
|
|
12
|
+
export type SqlTouch = { kind: "read" | "write" | "other"; tables: string[] };
|
|
13
|
+
|
|
14
|
+
// A table reference: optional quote/bracket, an identifier that may be schema-qualified.
|
|
15
|
+
const TABLE = "[`\"'\\[]?([A-Za-z_][\\w.]*)[`\"'\\]]?";
|
|
16
|
+
|
|
17
|
+
// schema.table -> table (tags use the bare name, as the table API does)
|
|
18
|
+
function bare(token: string): string {
|
|
19
|
+
const dot = token.lastIndexOf(".");
|
|
20
|
+
return dot >= 0 ? token.slice(dot + 1) : token;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalize(sql: string): string {
|
|
24
|
+
return sql
|
|
25
|
+
.replace(/--[^\n]*/g, " ") // line comments
|
|
26
|
+
.replace(/\/\*[\s\S]*?\*\//g, " ") // block comments
|
|
27
|
+
.replace(/\s+/g, " ")
|
|
28
|
+
.trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function collect(sql: string, re: RegExp): string[] {
|
|
32
|
+
const out = new Set<string>();
|
|
33
|
+
for (const m of sql.matchAll(re)) if (m[1]) out.add(bare(m[1]));
|
|
34
|
+
return [...out];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Classify a statement by its leading verb and pull the table(s) it touches.
|
|
38
|
+
export function tablesFromSql(sqlRaw: string): SqlTouch {
|
|
39
|
+
const sql = normalize(sqlRaw);
|
|
40
|
+
const verb = (sql.match(/^(\w+)/)?.[1] ?? "").toLowerCase();
|
|
41
|
+
switch (verb) {
|
|
42
|
+
case "select":
|
|
43
|
+
case "with": // CTE names may over-match → over-invalidate (safe), never under
|
|
44
|
+
return { kind: "read", tables: collect(sql, new RegExp(`\\b(?:from|join)\\s+${TABLE}`, "gi")) };
|
|
45
|
+
case "insert":
|
|
46
|
+
return { kind: "write", tables: collect(sql, new RegExp(`\\binto\\s+${TABLE}`, "gi")) };
|
|
47
|
+
case "update":
|
|
48
|
+
return { kind: "write", tables: collect(sql, new RegExp(`^update\\s+${TABLE}`, "gi")) };
|
|
49
|
+
case "delete":
|
|
50
|
+
return { kind: "write", tables: collect(sql, new RegExp(`\\bfrom\\s+${TABLE}`, "gi")) };
|
|
51
|
+
default:
|
|
52
|
+
return { kind: "other", tables: [] };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Record the reads/writes a raw statement implies, for cache auto-tag/invalidate.
|
|
57
|
+
export function tagSql(sql: string): void {
|
|
58
|
+
const t = tablesFromSql(sql);
|
|
59
|
+
if (t.kind === "read") for (const name of t.tables) recordTableRead(name);
|
|
60
|
+
else if (t.kind === "write") for (const name of t.tables) recordTableWrite(name);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Wrap a JuneDb so raw query/get/run auto-tag by parsed table name. The typed
|
|
64
|
+
// table API keeps tagging explicitly (precise, parser-independent); this covers
|
|
65
|
+
// the raw escape hatch so `cache(() => db.query("select ... from posts"))` is
|
|
66
|
+
// invalidated by a posts write instead of going silently stale.
|
|
67
|
+
export function taggingDb(db: JuneDb): JuneDb {
|
|
68
|
+
return {
|
|
69
|
+
...db,
|
|
70
|
+
query<T = unknown>(sql: string, params?: unknown[]) {
|
|
71
|
+
tagSql(sql);
|
|
72
|
+
return db.query<T>(sql, params);
|
|
73
|
+
},
|
|
74
|
+
get<T = unknown>(sql: string, params?: unknown[]) {
|
|
75
|
+
tagSql(sql);
|
|
76
|
+
return db.get<T>(sql, params);
|
|
77
|
+
},
|
|
78
|
+
run(sql: string, params?: unknown[]) {
|
|
79
|
+
tagSql(sql);
|
|
80
|
+
return db.run(sql, params);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|