@junejs/juno 0.0.2
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/LICENSE +21 -0
- package/README.md +27 -0
- package/package.json +28 -0
- package/src/batch.ts +73 -0
- package/src/index.ts +92 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 June.build
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# @junejs/juno
|
|
2
|
+
|
|
3
|
+
June's ergonomic data layer — a typed table API over the pure `JuneDb` contract
|
|
4
|
+
(`@junejs/core/resources`), so it runs over sqlite / D1 / Postgres alike. Juno
|
|
5
|
+
depends only on `@junejs/core` (inward); `@junejs/core` never imports Juno.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { juno } from "@junejs/juno";
|
|
9
|
+
|
|
10
|
+
const db = juno(ctx.db); // ctx.db is the injected `db` resource
|
|
11
|
+
await db.table("users").all();
|
|
12
|
+
await db.table("users").findBy({ email: "ada@x.dev" });
|
|
13
|
+
await db.table("users").insert({ name: "Ada" });
|
|
14
|
+
await db.table("users").update({ id: 1 }, { name: "Ada Lovelace" });
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## The magic is opt-in, not load-bearing
|
|
18
|
+
|
|
19
|
+
Juno is the **default** that ships at Tier 3: every read calls `recordTableRead`
|
|
20
|
+
and every write `recordTableWrite` (@junejs/core's public trace contract). That is
|
|
21
|
+
what makes `cache()` auto-tag by table and a mutation **auto-invalidate** the
|
|
22
|
+
cache (and push live RSC) with zero manual `revalidate()`.
|
|
23
|
+
|
|
24
|
+
This is a property of emitting the trace signals, **not** of using Juno. The
|
|
25
|
+
framework depends on the contract, never on Juno — so Prisma/Drizzle stay
|
|
26
|
+
first-class (Tier 1 = bring your own, untouched; Tier 2 = your ORM over `ctx.db`;
|
|
27
|
+
Tier 3 = the same magic via a thin shim). See `docs/data-layer-boundary.md`.
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@junejs/juno",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Juno — June's ergonomic data layer over the JuneDb contract. The default that ships with the agent-native magic (Tier 3).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://june.build",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "bun test",
|
|
16
|
+
"typecheck": "tsc --noEmit"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@junejs/core": "0.0.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@junejs/server": "0.0.0"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/junebuild/june"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/batch.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Auto-batch — the flagship data demo (rebuild-plan Phase 5; the render-level
|
|
2
|
+
// auto-batch / D1 8.8× number in bench/results.json). When many components each
|
|
3
|
+
// ask for a row by key during ONE render pass, a DataLoader-style loader
|
|
4
|
+
// coalesces them within a microtask tick into a SINGLE `where key in (...)`
|
|
5
|
+
// query: N+1 → 1. Per-request so keys never leak across requests.
|
|
6
|
+
|
|
7
|
+
import type { JuneDb } from "@junejs/core/resources";
|
|
8
|
+
import { recordTableRead } from "@junejs/core/instrumentation";
|
|
9
|
+
|
|
10
|
+
import type { Row } from "./index";
|
|
11
|
+
|
|
12
|
+
export type Loader<K, V> = {
|
|
13
|
+
load(key: K): Promise<V | null>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Generic batched loader: `batchFn(keys)` runs once per tick with all pending
|
|
17
|
+
// keys; `keyOf(row)` maps a result back to the key that requested it.
|
|
18
|
+
export function createLoader<K, V>(
|
|
19
|
+
batchFn: (keys: K[]) => Promise<V[]>,
|
|
20
|
+
keyOf: (value: V) => K,
|
|
21
|
+
): Loader<K, V> {
|
|
22
|
+
let queue: { key: K; resolve: (v: V | null) => void; reject: (e: unknown) => void }[] = [];
|
|
23
|
+
let scheduled = false;
|
|
24
|
+
|
|
25
|
+
function flush() {
|
|
26
|
+
const batch = queue;
|
|
27
|
+
queue = [];
|
|
28
|
+
scheduled = false;
|
|
29
|
+
// Dedupe keys so repeated asks for the same row cost nothing extra.
|
|
30
|
+
const keys = [...new Set(batch.map((b) => b.key))];
|
|
31
|
+
batchFn(keys).then(
|
|
32
|
+
(values) => {
|
|
33
|
+
const byKey = new Map(values.map((v) => [keyOf(v), v]));
|
|
34
|
+
for (const b of batch) b.resolve(byKey.get(b.key) ?? null);
|
|
35
|
+
},
|
|
36
|
+
(err) => {
|
|
37
|
+
for (const b of batch) b.reject(err);
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
load(key: K) {
|
|
44
|
+
return new Promise<V | null>((resolve, reject) => {
|
|
45
|
+
queue.push({ key, resolve, reject });
|
|
46
|
+
if (!scheduled) {
|
|
47
|
+
scheduled = true;
|
|
48
|
+
queueMicrotask(flush);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A by-key loader over a JuneDb table: `loader.load(id)` calls coalesce into one
|
|
56
|
+
// `select * from <table> where <key> in (?, ?, ...)`. Build one per request.
|
|
57
|
+
export function tableLoader<V extends Row = Row>(
|
|
58
|
+
db: JuneDb,
|
|
59
|
+
table: string,
|
|
60
|
+
key = "id",
|
|
61
|
+
): Loader<string | number, V> {
|
|
62
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
63
|
+
throw new Error(`unsafe SQL identifier: ${table}.${key}`);
|
|
64
|
+
}
|
|
65
|
+
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
|
+
},
|
|
71
|
+
(row) => (row as Row)[key] as string | number,
|
|
72
|
+
);
|
|
73
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Juno — June's ergonomic data layer. A typed table API over the @junejs/core
|
|
2
|
+
// JuneDb contract (so it works over sqlite / D1 / Postgres alike), depending ONLY
|
|
3
|
+
// on @junejs/core (inward; @junejs/core never imports Juno). docs/data-layer-boundary.md.
|
|
4
|
+
//
|
|
5
|
+
// THE MAGIC (Tier 3): every read calls recordTableRead, every write
|
|
6
|
+
// recordTableWrite — @junejs/core's PUBLIC trace contract. That is what makes cache
|
|
7
|
+
// auto-tag by table and a mutation auto-invalidate + push live RSC, with zero
|
|
8
|
+
// manual revalidate(). Juno emits these natively; Drizzle/Prisma reach the same
|
|
9
|
+
// tier with a thin shim. The framework depends on the trace contract, not on Juno.
|
|
10
|
+
|
|
11
|
+
import type { JuneDb, RunResult } from "@junejs/core/resources";
|
|
12
|
+
import { recordTableRead, recordTableWrite } from "@junejs/core/instrumentation";
|
|
13
|
+
|
|
14
|
+
import { tableLoader, type Loader } from "./batch";
|
|
15
|
+
|
|
16
|
+
export { createLoader, tableLoader, type Loader } from "./batch";
|
|
17
|
+
|
|
18
|
+
export type Row = Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
// Guard table/column names (identifiers can't be parameterized). Values always
|
|
21
|
+
// go through bound `?` placeholders, so they are injection-safe by construction.
|
|
22
|
+
function ident(name: string): string {
|
|
23
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(`unsafe SQL identifier: ${name}`);
|
|
24
|
+
return name;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class Table<T extends Row = Row> {
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly db: JuneDb,
|
|
30
|
+
private readonly name: string,
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
async all(): Promise<T[]> {
|
|
34
|
+
recordTableRead(this.name); // auto-tag: a cache() around this gets table:<name>
|
|
35
|
+
return this.db.query<T>(`select * from ${ident(this.name)}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
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 ")}` : "";
|
|
42
|
+
return this.db.get<T>(`select * from ${ident(this.name)}${clause} limit 1`, Object.values(where));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async insert(values: Partial<T>): Promise<RunResult> {
|
|
46
|
+
recordTableWrite(this.name); // auto-invalidate: invokeAction drops table:<name>
|
|
47
|
+
const cols = Object.keys(values).map(ident);
|
|
48
|
+
const placeholders = cols.map(() => "?").join(", ");
|
|
49
|
+
return this.db.run(
|
|
50
|
+
`insert into ${ident(this.name)} (${cols.join(", ")}) values (${placeholders})`,
|
|
51
|
+
Object.values(values),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async update(where: Partial<T>, values: Partial<T>): Promise<RunResult> {
|
|
56
|
+
recordTableWrite(this.name);
|
|
57
|
+
const set = Object.keys(values).map((k) => `${ident(k)} = ?`).join(", ");
|
|
58
|
+
const cond = Object.keys(where).map((k) => `${ident(k)} = ?`).join(" and ");
|
|
59
|
+
return this.db.run(
|
|
60
|
+
`update ${ident(this.name)} set ${set} where ${cond}`,
|
|
61
|
+
[...Object.values(values), ...Object.values(where)],
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async delete(where: Partial<T>): Promise<RunResult> {
|
|
66
|
+
recordTableWrite(this.name);
|
|
67
|
+
const cond = Object.keys(where).map((k) => `${ident(k)} = ?`).join(" and ");
|
|
68
|
+
return this.db.run(`delete from ${ident(this.name)} where ${cond}`, Object.values(where));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A per-request by-key loader: concurrent .load(key) calls during one render
|
|
72
|
+
// pass coalesce into a single `where key in (...)` query (N+1 → 1). Build one
|
|
73
|
+
// per request so keys never leak across requests.
|
|
74
|
+
loader(key = "id"): Loader<string | number, T> {
|
|
75
|
+
return tableLoader<T>(this.db, this.name, key);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type Juno = {
|
|
80
|
+
table<T extends Row = Row>(name: string): Table<T>;
|
|
81
|
+
db: JuneDb;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Wrap any JuneDb handle (ctx.db) in Juno's ergonomic surface.
|
|
85
|
+
export function juno(db: JuneDb): Juno {
|
|
86
|
+
return {
|
|
87
|
+
db,
|
|
88
|
+
table<T extends Row = Row>(name: string) {
|
|
89
|
+
return new Table<T>(db, name);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|