@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 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.18.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.29.0"
26
+ "@sproutboat/runtime": "^0.30.0"
27
27
  }
28
28
  }
@@ -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
- export function createD1Store(executionMs = 0, capability = "") {
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(`D1_EXEC_ERROR: Error in line ${i + 1}: ${line}: ${error instanceof Error ? error.message : String(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
  )();