@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.
Files changed (3) hide show
  1. package/package.json +3 -3
  2. package/src/index.ts +56 -6
  3. 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.12",
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.12"
19
+ "@junejs/core": "0.0.13"
20
20
  },
21
21
  "devDependencies": {
22
- "@junejs/server": "0.0.12"
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).map(ident);
41
- const clause = keys.length ? ` where ${keys.map((k) => `${k} = ?`).join(" and ")}` : "";
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
+ }