@sproutboat/wire 0.18.0 → 0.19.0
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/CHANGELOG.md +34 -0
- package/package.json +2 -2
- package/src/broker.test.ts +43 -0
- package/src/broker.ts +25 -0
- package/src/d1.ts +19 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# @sproutboat/wire
|
|
2
2
|
|
|
3
|
+
## 0.19.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 140f0f7: Durable Objects `ctx.storage.sql` from compatibility date `2026-10-29`, on every
|
|
8
|
+
transport. Each object gets its own SQLite database.
|
|
9
|
+
|
|
10
|
+
- `exec(query, ...bindings)` runs one statement and returns a cursor with `toArray()`,
|
|
11
|
+
`one()`, `raw()`, `next()`, iteration, `columnNames`, `rowsRead` and `rowsWritten`.
|
|
12
|
+
- BLOBs come back as ArrayBuffers.
|
|
13
|
+
- Foreign keys are enforced.
|
|
14
|
+
- `BEGIN`, `SAVEPOINT` and the other transaction statements throw workerd's error. Use
|
|
15
|
+
`storage.transactionSync(fn)`, which rolls back if `fn` throws.
|
|
16
|
+
- `sql.databaseSize` gives the database's size in bytes.
|
|
17
|
+
- Errors read as in workerd, for example `near "SELEKT": syntax error at offset 0:
|
|
18
|
+
SQLITE_ERROR`.
|
|
19
|
+
|
|
20
|
+
Every Durable Object class gets `sql`, since `sproutboat.jsonc` has no
|
|
21
|
+
`new_sqlite_classes`. The key-value API keeps its own store.
|
|
22
|
+
|
|
23
|
+
Where the databases live:
|
|
24
|
+
- **Standalone executables and sproutd:** in `<data>/do-sql/CLASS/ID.sqlite`. At most 8
|
|
25
|
+
stay open, and the one used longest ago closes for the next one.
|
|
26
|
+
- **Broker (wire):** new op `do.sql`. With `doDb`, the databases sit in
|
|
27
|
+
`<doDb without .sqlite>.do-sql/` next to it, so they survive a redeploy and stay
|
|
28
|
+
separate per project. Without a SQL pool, the broker keeps at most 64 open
|
|
29
|
+
(`createD1Store`'s new `maxOpen`).
|
|
30
|
+
|
|
31
|
+
### Patch Changes
|
|
32
|
+
|
|
33
|
+
- Updated dependencies [9167b44]
|
|
34
|
+
- Updated dependencies [140f0f7]
|
|
35
|
+
- @sproutboat/runtime@0.30.0
|
|
36
|
+
|
|
3
37
|
## 0.18.0
|
|
4
38
|
|
|
5
39
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sproutboat/wire",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -23,6 +23,6 @@
|
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@sproutboat/assets": "^0.3.0",
|
|
26
|
-
"@sproutboat/runtime": "^0.
|
|
26
|
+
"@sproutboat/runtime": "^0.30.0"
|
|
27
27
|
}
|
|
28
28
|
}
|
package/src/broker.test.ts
CHANGED
|
@@ -1079,6 +1079,49 @@ test("cronMatches: fields, steps, ranges, lists (UTC)", () => {
|
|
|
1079
1079
|
// #74 — a binding wired to an account-level resource stores in its own file
|
|
1080
1080
|
// under resourceDir, keyed by id, so the data outlives a "redeploy" (a new
|
|
1081
1081
|
// broker with a fresh per-deployment db but the same resourceDir).
|
|
1082
|
+
test("Durable Object SQL: one database per object, beside its project's doDb, D1-strict replies", async () => {
|
|
1083
|
+
const root = mkdtempSync(join(tmpdir(), "sb-broker-do-sql-"));
|
|
1084
|
+
try {
|
|
1085
|
+
for (const dep of ["dep", "dep2", "dep3"]) mkdirSync(join(root, dep));
|
|
1086
|
+
const bindings = { do: [{ binding: "SQL", className: "SqlObject" }] };
|
|
1087
|
+
const a = "a".repeat(64);
|
|
1088
|
+
const b2 = "b".repeat(64);
|
|
1089
|
+
const sql = (b: ReturnType<typeof createBroker>, file: string, text: string, params: JsonValue[] = []) =>
|
|
1090
|
+
b.dispatch({ op: "do.sql", cls: "SqlObject", file, sql: text, params });
|
|
1091
|
+
const one = createBroker({ db: join(root, "dep", "state.sqlite"), doDb: join(root, "do_p1.sqlite"), bindings });
|
|
1092
|
+
await sql(one, a, "CREATE TABLE t (v, b BLOB)");
|
|
1093
|
+
expect(await sql(one, a, "INSERT INTO t VALUES (?, ?), (?, ?)", [1, { $blob: "0102" }, 2, null])).toMatchObject({
|
|
1094
|
+
meta: { changes: 2 },
|
|
1095
|
+
});
|
|
1096
|
+
expect(await sql(one, a, "SELECT v, b FROM t ORDER BY v")).toMatchObject({
|
|
1097
|
+
columns: ["v", "b"],
|
|
1098
|
+
rows: [
|
|
1099
|
+
[1, { $blob: "0102" }],
|
|
1100
|
+
[2, null],
|
|
1101
|
+
],
|
|
1102
|
+
});
|
|
1103
|
+
// Another object has its own database.
|
|
1104
|
+
await expect(sql(one, b2, "SELECT v FROM t")).rejects.toThrow("D1_ERROR: no such table: t");
|
|
1105
|
+
await expect(sql(one, a, "SELEKT 1")).rejects.toThrow('near "SELEKT": syntax error');
|
|
1106
|
+
await expect(sql(one, "../x", "SELECT 1")).rejects.toThrow("invalid Durable Object SQL database");
|
|
1107
|
+
await expect(one.dispatch({ op: "do.sql", cls: "Nope", file: a, sql: "SELECT 1", params: [] })).rejects.toThrow(
|
|
1108
|
+
"not bound",
|
|
1109
|
+
);
|
|
1110
|
+
one.close();
|
|
1111
|
+
expect(existsSync(join(root, "do_p1.do-sql", "SqlObject", `${a}.sqlite`))).toBe(true);
|
|
1112
|
+
|
|
1113
|
+
// A redeploy keeps it; another project with the same class and id does not see it.
|
|
1114
|
+
const again = createBroker({ db: join(root, "dep2", "state.sqlite"), doDb: join(root, "do_p1.sqlite"), bindings });
|
|
1115
|
+
expect((await sql(again, a, "SELECT count(*) AS n FROM t")).rows).toEqual([[2]]);
|
|
1116
|
+
again.close();
|
|
1117
|
+
const other = createBroker({ db: join(root, "dep3", "state.sqlite"), doDb: join(root, "do_p2.sqlite"), bindings });
|
|
1118
|
+
await expect(sql(other, a, "SELECT v FROM t")).rejects.toThrow("no such table: t");
|
|
1119
|
+
other.close();
|
|
1120
|
+
} finally {
|
|
1121
|
+
rmSync(root, { recursive: true, force: true });
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1082
1125
|
test("#207: Durable Object storage and alarms in doDb outlive a redeploy", async () => {
|
|
1083
1126
|
const root = mkdtempSync(join(tmpdir(), "sb-broker-do-"));
|
|
1084
1127
|
try {
|
package/src/broker.ts
CHANGED
|
@@ -675,6 +675,27 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
675
675
|
return d1Session ? d1Session.execute(request) : d1Store.execute({ ...request, owner: "dedicated" });
|
|
676
676
|
};
|
|
677
677
|
|
|
678
|
+
// Durable Objects storage.sql (the shim's compatibility date 2026-10-29): one SQLite file per
|
|
679
|
+
// object, `<do-sql>/CLASS/FILE.sqlite`, run as a strict D1 query. The directory is named
|
|
680
|
+
// after the DO store file: several projects keep their doDb side by side.
|
|
681
|
+
// FILE is the object's 64-hex id (or a hash of an older id); the class must be bound.
|
|
682
|
+
// Without a SQL pool, at most 64 stay open; the one used longest ago closes first.
|
|
683
|
+
const doSqlDir = opts.doDb
|
|
684
|
+
? `${resolve(opts.doDb).replace(/\.sqlite$/, "")}.do-sql`
|
|
685
|
+
: inMemory
|
|
686
|
+
? null
|
|
687
|
+
: join(dirname(resolve(dbPath)), "do-sql");
|
|
688
|
+
const doSqlStore = createD1Store(0, "", 64);
|
|
689
|
+
const doSql = (msg: Frame): Frame | Promise<Frame> => {
|
|
690
|
+
const cls = requireDoClass(msg.cls);
|
|
691
|
+
const file = str(msg.file);
|
|
692
|
+
if (!/^[0-9a-f]{64}$/.test(file)) throw new Error("invalid Durable Object SQL database");
|
|
693
|
+
const path = doSqlDir ? resolve(doSqlDir, cls, `${file}.sqlite`) : ":memory:";
|
|
694
|
+
const key = path === ":memory:" ? `${token}:${dbPath}:do-sql:${cls}:${file}` : path;
|
|
695
|
+
const request = { key, path, name: `do-sql:${cls}`, msg: { ...msg, op: "d1.query", strict: true } };
|
|
696
|
+
return d1Session ? d1Session.execute(request) : doSqlStore.execute({ ...request, owner: "dedicated" });
|
|
697
|
+
};
|
|
698
|
+
|
|
678
699
|
/**
|
|
679
700
|
* #56 — an object's bytes never enter SQLite; they live in their own file,
|
|
680
701
|
* named by a hash of bucket+key (so unicode keys and directory traversal
|
|
@@ -1548,6 +1569,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1548
1569
|
case "d1.exec":
|
|
1549
1570
|
case "d1.backup":
|
|
1550
1571
|
return await d1(requireD1(msg.db), msg);
|
|
1572
|
+
case "do.sql":
|
|
1573
|
+
return await doSql(msg);
|
|
1551
1574
|
|
|
1552
1575
|
case "ratelimit.check": {
|
|
1553
1576
|
// #69 — fixed window; limit/period arrive in the message. Same upsert as
|
|
@@ -2058,6 +2081,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
2058
2081
|
"d1.exec",
|
|
2059
2082
|
"d1.batch",
|
|
2060
2083
|
"d1.backup",
|
|
2084
|
+
"do.sql",
|
|
2061
2085
|
"ratelimit.check",
|
|
2062
2086
|
"r2.put",
|
|
2063
2087
|
"r2.multipart.create",
|
|
@@ -2340,6 +2364,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
2340
2364
|
for (const t of timers) clearInterval(t);
|
|
2341
2365
|
d1Session?.close();
|
|
2342
2366
|
d1Store.close();
|
|
2367
|
+
doSqlStore.close();
|
|
2343
2368
|
for (const conn of resourceDbs.values()) conn.close();
|
|
2344
2369
|
if (doDb !== db) doDb.close();
|
|
2345
2370
|
db.close();
|
package/src/d1.ts
CHANGED
|
@@ -77,7 +77,10 @@ export function d1Error(error: Error): Error {
|
|
|
77
77
|
}
|
|
78
78
|
export type D1Request = { owner: string; key: string; path: string; name: string; msg: Frame };
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
/** maxOpen > 0 (Durable Object SQL, one file per object): at most that many connections stay
|
|
81
|
+
* open, and the one used longest ago is closed for a new one. A closed database reopens on
|
|
82
|
+
* its next call. */
|
|
83
|
+
export function createD1Store(executionMs = 0, capability = "", maxOpen = 0) {
|
|
81
84
|
if (executionMs && !/^[a-f0-9]{64}$/.test(capability)) throw new Error("invalid host budget capability");
|
|
82
85
|
const connections = new Map<string, { db: Database; owners: Set<string> }>();
|
|
83
86
|
// Strict calls (the shim's compatibility date 2026-10-24): the parameter count must match
|
|
@@ -145,6 +148,18 @@ export function createD1Store(executionMs = 0, capability = "") {
|
|
|
145
148
|
return {
|
|
146
149
|
execute({ owner, key, path, name, msg }: D1Request): Frame {
|
|
147
150
|
let entry = connections.get(key);
|
|
151
|
+
if (entry && maxOpen) {
|
|
152
|
+
// Most recently used last: Map iteration follows insertion order.
|
|
153
|
+
connections.delete(key);
|
|
154
|
+
connections.set(key, entry);
|
|
155
|
+
}
|
|
156
|
+
if (!entry && maxOpen && connections.size >= maxOpen) {
|
|
157
|
+
const [oldest, old] = connections.entries().next().value ?? [];
|
|
158
|
+
if (oldest !== undefined && old) {
|
|
159
|
+
connections.delete(oldest);
|
|
160
|
+
old.db.close();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
148
163
|
if (!entry) {
|
|
149
164
|
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
|
|
150
165
|
const db = new Database(path, { create: true });
|
|
@@ -200,7 +215,9 @@ export function createD1Store(executionMs = 0, capability = "") {
|
|
|
200
215
|
try {
|
|
201
216
|
db.exec(line);
|
|
202
217
|
} catch (error) {
|
|
203
|
-
throw new Error(
|
|
218
|
+
throw new Error(
|
|
219
|
+
`D1_EXEC_ERROR: Error in line ${i + 1}: ${line}: ${error instanceof Error ? error.message : String(error)}`,
|
|
220
|
+
);
|
|
204
221
|
}
|
|
205
222
|
}),
|
|
206
223
|
)();
|