@sproutboat/runtime 0.36.0 → 0.37.1

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.
@@ -157,3 +157,139 @@ test("before 2026-10-31 without settings, the outcome has only ack and retry", a
157
157
  expect(legacy).toEqual(["dlq", true, 5000]);
158
158
  expect(out).toEqual({ ack: [], retry: ["a"] });
159
159
  });
160
+
161
+ // ---- 2026-11-05 ----
162
+
163
+ const failure = (p) =>
164
+ p.then(
165
+ () => "accepted",
166
+ (e) => e.name + ": " + e.message,
167
+ );
168
+
169
+ test("2026-11-05: send() checks arguments and delays as workerd does", async () => {
170
+ const { QUEUE } = env("2026-11-05");
171
+ expect(await failure(QUEUE.send())).toBe(
172
+ "TypeError: Failed to execute 'send' on 'WorkerQueue': parameter 1 is not of type 'JsValue'.",
173
+ );
174
+ expect(await failure(QUEUE.send(1, "x"))).toBe(
175
+ "TypeError: Failed to execute 'send' on 'WorkerQueue': parameter 2 is not of type 'SendOptions'.",
176
+ );
177
+ expect(await failure(QUEUE.send(1, null))).toBe("accepted");
178
+ expect(await failure(QUEUE.send(1, { delaySeconds: -1 }))).toStartWith("Error: Queue send failed: delaySeconds");
179
+ expect(await failure(QUEUE.send(1, { delaySeconds: 86401 }))).toStartWith("Error: Queue send failed: delaySeconds");
180
+ expect(await failure(QUEUE.send(1, { delaySeconds: 86400 }))).toBe("accepted");
181
+ expect(await failure(QUEUE.send(1, { delaySeconds: 1.5 }))).toBe("accepted");
182
+ expect(await failure(QUEUE.sendBatch([{ body: 1 }], { delaySeconds: 86401 }))).toStartWith(
183
+ "Error: Queue send failed",
184
+ );
185
+ expect(await failure(QUEUE.sendBatch([{ body: 1, delaySeconds: -5 }]))).toStartWith("Error: Queue send failed");
186
+ expect(await failure(QUEUE.sendBatch([1, 2]))).toBe(
187
+ "TypeError: Incorrect type: the provided value is not of type 'MessageSendRequest'.",
188
+ );
189
+ expect(await failure(QUEUE.send(new Uint8Array([1]).buffer, { contentType: "bytes" }))).toBe(
190
+ 'TypeError: Content Type "bytes" requires a value of type ArrayBufferView, but received: object',
191
+ );
192
+ expect(await failure(QUEUE.send(new Uint8Array([1]), { contentType: "bytes" }))).toBe("accepted");
193
+ });
194
+
195
+ test("before 2026-11-05 the same arguments are accepted or answer as before", async () => {
196
+ const { QUEUE } = env("2026-11-01");
197
+ expect(await failure(QUEUE.send(1, { delaySeconds: 86401 }))).toBe("accepted");
198
+ expect(await failure(QUEUE.send(1, "x"))).toBe("accepted");
199
+ expect(await failure(QUEUE.sendBatch([{ body: 1, delaySeconds: -5 }]))).toBe("accepted");
200
+ expect(await failure(QUEUE.send(new Uint8Array([1]).buffer, { contentType: "bytes" }))).toBe("accepted");
201
+ expect(await failure(QUEUE.send())).toBe("TypeError: Message body cannot be undefined");
202
+ expect(sent.find((r) => r.delaySeconds === 86401)).toBeDefined();
203
+ });
204
+
205
+ const roundTrip = async (compat, value) => {
206
+ const { QUEUE } = env(compat);
207
+ await QUEUE.send(value, { contentType: "v8" });
208
+ const body = sent[sent.length - 1].body;
209
+ let seen;
210
+ await run(
211
+ {
212
+ queue(batch) {
213
+ seen = batch.messages[0];
214
+ },
215
+ },
216
+ { queue: "QUEUE", messages: [message("a", body)] },
217
+ ctx,
218
+ );
219
+ return { body, seen };
220
+ };
221
+
222
+ test("2026-11-05: the v8 content type keeps shared and circular references, holes and invalid dates", async () => {
223
+ const shared = { z: 1 };
224
+ const circular = { n: 1 };
225
+ circular.self = circular;
226
+ const holes = [1];
227
+ holes[3] = 4;
228
+ const { body, seen } = await roundTrip("2026-11-05", {
229
+ a: shared,
230
+ b: shared,
231
+ c: circular,
232
+ holes,
233
+ d: new Date(NaN),
234
+ e: new TypeError("boom"),
235
+ m: new Map([[shared, new Set([shared])]]),
236
+ });
237
+ expect(body.startsWith("#sbq1:v8r:")).toBe(true);
238
+ const v = seen.body;
239
+ expect(v.a).toBe(v.b);
240
+ expect(v.c.self).toBe(v.c);
241
+ expect(v.holes.length).toBe(4);
242
+ expect(1 in v.holes).toBe(false);
243
+ expect(v.holes[3]).toBe(4);
244
+ expect(Number.isNaN(v.d.getTime())).toBe(true);
245
+ expect(v.e).toBeInstanceOf(TypeError);
246
+ expect(v.e.message).toBe("boom");
247
+ const [key, set] = [...v.m][0];
248
+ expect(key).toBe(v.a);
249
+ expect([...set][0]).toBe(v.a);
250
+ expect(Object.getOwnPropertyDescriptor(seen, "body").writable).toBe(false);
251
+ expect(Object.getOwnPropertyDescriptor(seen, "id").writable).toBe(false);
252
+ });
253
+
254
+ test("before 2026-11-05 the v8 content type is the earlier codec, and v8r messages decode at any date", async () => {
255
+ const { body, seen } = await roundTrip("2026-11-01", { d: new Date(1000), m: new Map([[1, 2]]) });
256
+ expect(body.startsWith("#sbq1:v8:")).toBe(true);
257
+ expect(seen.body.m.get(1)).toBe(2);
258
+ const circular = {};
259
+ circular.self = circular;
260
+ const later = await roundTrip("2026-11-05", circular);
261
+ globalThis.__sbCompat = "2026-10-31";
262
+ let again;
263
+ await run(
264
+ {
265
+ queue(batch) {
266
+ again = batch.messages[0].body;
267
+ },
268
+ },
269
+ { queue: "QUEUE", messages: [message("a", later.body)] },
270
+ ctx,
271
+ );
272
+ expect(again.self).toBe(again);
273
+ expect(Object.getOwnPropertyDescriptor(seen, "body").writable).toBe(true);
274
+ });
275
+
276
+ test("2026-11-05: a consumer announces its queue to the store before the handler runs", async () => {
277
+ globalThis.__sbCompat = "2026-11-05";
278
+ sent = [];
279
+ let at;
280
+ await run(
281
+ {
282
+ queue() {
283
+ at = sent.length;
284
+ },
285
+ },
286
+ { queue: "QUEUE", messages: [message("a", "1")] },
287
+ ctx,
288
+ );
289
+ expect(sent[0]).toMatchObject({ op: "queue.consumer", queue: "QUEUE" });
290
+ expect(at).toBe(1);
291
+ globalThis.__sbCompat = "2026-11-01";
292
+ sent = [];
293
+ await run({ queue() {} }, { queue: "QUEUE", messages: [message("a", "1")] }, ctx);
294
+ expect(sent).toEqual([]);
295
+ });
@@ -61,6 +61,8 @@ extern int sqlite3_bind_parameter_count(sqlite3_stmt*);
61
61
  extern int sqlite3_stmt_readonly(sqlite3_stmt*);
62
62
  extern int sqlite3_extended_errcode(sqlite3*);
63
63
  extern int sqlite3_error_offset(sqlite3*);
64
+ extern const char* sqlite3_sql(sqlite3_stmt*);
65
+ extern int sqlite3_total_changes(sqlite3*);
64
66
 
65
67
  // baronunread/sproutboat#176 — defined in render.js, alongside
66
68
  // porf_native_fetch_read_value (which every inline-C block below already
@@ -2138,7 +2140,7 @@ static char* sb_sql_script(const char* path, const char* sql) {
2138
2140
  if (idx < 0 && sb_db_capture_failed(path)) { sb_puts(&b, "{\"ok\":false,\"error\":\"" SB_LTX_FAILED "\",\"code\":10}"); return b.p; }
2139
2141
  if (idx < 0) { sb_puts(&b, "{\"ok\":false,\"error\":\"cannot open database\"}"); return b.p; }
2140
2142
  char* err = 0;
2141
- int script_rc = 0, capture_failed = 0, quota_full = 0, failure_code = 0;
2143
+ int script_rc = 0, capture_failed = 0, quota_full = 0, failure_code = 0, failure_offset = -1;
2142
2144
  char failure[512] = { 0 };
2143
2145
  if (sb_ltx_states[idx].db) {
2144
2146
  const char *next = sql;
@@ -2149,19 +2151,25 @@ static char* sb_sql_script(const char* path, const char* sql) {
2149
2151
  if (!script_rc && st) { do { script_rc = sqlite3_step(st); } while (script_rc == SB_SQLITE_ROW); if (script_rc == SB_SQLITE_DONE) script_rc = 0; }
2150
2152
  // Keep the statement's error before capture runs statements of its own (it would
2151
2153
  // read "not an error" after them).
2152
- if (script_rc && !failure[0]) { snprintf(failure, sizeof(failure), "%s", sqlite3_errmsg(sb_dbs[idx])); failure_code = sqlite3_extended_errcode(sb_dbs[idx]); }
2154
+ if (script_rc && !failure[0]) { snprintf(failure, sizeof(failure), "%s", sqlite3_errmsg(sb_dbs[idx])); failure_code = sqlite3_extended_errcode(sb_dbs[idx]); failure_offset = sqlite3_error_offset(sb_dbs[idx]); }
2153
2155
  if (st) sqlite3_finalize(st);
2154
2156
  if (sb_ltx_after(idx)) { script_rc = 10; capture_failed = 1; }
2155
2157
  if (!tail || tail <= next) break;
2156
2158
  next = tail;
2159
+ // as sqlite3_exec does: the next statement starts at its first non-space character, so an
2160
+ // error offset counts from there
2161
+ while (*next == ' ' || *next == '\t' || *next == '\n' || *next == '\r' || *next == '\f') next++;
2157
2162
  }
2158
- } else script_rc = sqlite3_exec(sb_dbs[idx], sql, 0, 0, &err);
2163
+ } else {
2164
+ script_rc = sqlite3_exec(sb_dbs[idx], sql, 0, 0, &err);
2165
+ if (script_rc) { failure_code = sqlite3_extended_errcode(sb_dbs[idx]); failure_offset = sqlite3_error_offset(sb_dbs[idx]); }
2166
+ }
2159
2167
  if (script_rc != 0) {
2160
2168
  const char* m = quota_full ? SB_QUOTA_FULL : capture_failed ? SB_LTX_FAILED : failure[0] ? failure : err ? err : sqlite3_errmsg(sb_dbs[idx]);
2161
2169
  sb_puts(&b, "{\"ok\":false,\"error\":");
2162
2170
  sb_putjson_text(&b, m, strlen(m));
2163
2171
  char code[32];
2164
- snprintf(code, sizeof(code), ",\"code\":%d}", quota_full ? 13 : capture_failed ? 10 : failure[0] ? failure_code : 0);
2172
+ snprintf(code, sizeof(code), ",\"code\":%d,\"offset\":%d}", quota_full ? 13 : capture_failed ? 10 : failure_code, quota_full || capture_failed ? -1 : failure_offset);
2165
2173
  sb_puts(&b, code);
2166
2174
  return b.p;
2167
2175
  }
@@ -2187,6 +2195,39 @@ static char* sb_sql_error(sqlite3* db, const char* message, int code, int skip)
2187
2195
  // D1 calls from compatibility date 2026-10-24 put this comment first: the statement must
2188
2196
  // get exactly as many parameters as it has, like D1.
2189
2197
  #define SB_D1_STRICT "/*sb-d1-strict*/"
2198
+ // 2026-11-03: a statement with more behind it runs all of them, as workerd does (the marker follows
2199
+ // SB_D1_STRICT). A query without a tail never looks past its first statement.
2200
+ #define SB_D1_MULTI "/*sb-d1-multi*/"
2201
+ #define SB_MULTI_PARAMS "When executing multiple SQL statements in a single call, only the last statement can have parameters."
2202
+ #define SB_NO_STATEMENT "SQL code did not contain a statement."
2203
+ // Runs st (the first statement, cached) and every statement after it but the last, inside one
2204
+ // savepoint: they all take effect or none does. Returns the last statement, prepared here (the
2205
+ // caller finalizes it and ends the savepoint), or NULL with *error set (savepoint rolled back).
2206
+ static sqlite3_stmt* sb_multi_open(int idx, sqlite3* db, sqlite3_stmt* st, const char* rest, char** error) {
2207
+ sqlite3_stmt* cur = st;
2208
+ int cached = 1;
2209
+ *error = 0;
2210
+ sqlite3_exec(db, "SAVEPOINT sb_d1_multi", 0, 0, 0);
2211
+ for (;;) {
2212
+ rest = sb_skip_ws(rest);
2213
+ if (!*rest) return cur;
2214
+ if (sqlite3_bind_parameter_count(cur) > 0) { *error = sb_sql_error(db, SB_MULTI_PARAMS, 0, 0); break; }
2215
+ if (sb_quota_frozen[idx] && !sqlite3_stmt_readonly(cur)) { *error = sb_sql_error(db, SB_QUOTA_FULL, 13, 0); break; }
2216
+ int rc;
2217
+ while ((rc = sqlite3_step(cur)) == SB_SQLITE_ROW) {}
2218
+ if (rc != SB_SQLITE_DONE) { *error = sb_sql_error(db, 0, -1, 0); break; }
2219
+ if (cached) sqlite3_reset(cur); else sqlite3_finalize(cur);
2220
+ cur = 0;
2221
+ sqlite3_stmt* next = 0; const char* tail = 0;
2222
+ if (sqlite3_prepare_v2(db, rest, -1, &next, &tail)) { *error = sb_sql_error(db, 0, -1, 0); break; }
2223
+ if (!next) { *error = sb_sql_error(db, SB_NO_STATEMENT, 0, 0); break; }
2224
+ cur = next; cached = 0; rest = tail ? tail : "";
2225
+ }
2226
+ if (cur) { if (cached) sqlite3_reset(cur); else sqlite3_finalize(cur); }
2227
+ sqlite3_exec(db, "ROLLBACK TO sb_d1_multi", 0, 0, 0);
2228
+ sqlite3_exec(db, "RELEASE sb_d1_multi", 0, 0, 0);
2229
+ return 0;
2230
+ }
2190
2231
 
2191
2232
  // Run one statement. Returns malloc'd JSON:
2192
2233
  // {"ok":true,"cols":[...],"rows":[[...]],"changes":n,"rowid":n}
@@ -2243,18 +2284,38 @@ static char* sb_sql_run_x(const char* path, const char* sql, const char* params,
2243
2284
  if (idx < 0) { sb_puts(&b, "{\"ok\":false,\"error\":\"cannot open database\"}"); return b.p; }
2244
2285
  sqlite3* db = sb_dbs[idx];
2245
2286
  const int strict = strncmp(sql, SB_D1_STRICT, sizeof(SB_D1_STRICT) - 1) == 0;
2287
+ const int multi = strict && strncmp(sql + sizeof(SB_D1_STRICT) - 1, SB_D1_MULTI, sizeof(SB_D1_MULTI) - 1) == 0;
2288
+ const int skip = strict ? (int)sizeof(SB_D1_STRICT) - 1 + (multi ? (int)sizeof(SB_D1_MULTI) - 1 : 0) : 0;
2246
2289
  // Durable Object SQL databases return BLOBs as D1 does.
2247
2290
  const int d1 = strstr(path, "/d1/") != 0 || sb_is_do_sql(path);
2248
2291
  sqlite3_stmt* st = sb_stmt_get(idx, db, sql);
2249
- if (!st) return sb_sql_error(db, 0, -1, strict ? (int)sizeof(SB_D1_STRICT) - 1 : 0);
2292
+ if (!st) return sb_sql_error(db, 0, -1, skip);
2293
+ // More than one statement: workerd runs them all and answers with the last one's rows; only the last
2294
+ // can take parameters. (sqlite3_sql is the first statement's text, a prefix of sql.)
2295
+ sqlite3_stmt* owned = 0;
2296
+ long long multi_before = 0;
2297
+ int in_multi = 0;
2298
+ if (multi) {
2299
+ const char* own = sqlite3_sql(st);
2300
+ size_t olen = own ? strlen(own) : 0;
2301
+ if (own && strncmp(sql, own, olen) == 0 && *sb_skip_ws(sql + olen)) {
2302
+ char* merr = 0;
2303
+ multi_before = sqlite3_total_changes(db);
2304
+ owned = sb_multi_open(idx, db, st, sql + olen, &merr);
2305
+ if (!owned) return merr;
2306
+ st = owned; in_multi = 1;
2307
+ }
2308
+ }
2250
2309
  int bound = params && *params ? sb_bind_params(st, params) : 0;
2251
2310
  if (sb_quota_frozen[idx] && !sqlite3_stmt_readonly(st)) {
2252
- sqlite3_reset(st);
2253
- return sb_sql_error(db, SB_QUOTA_FULL, 13, 0);
2311
+ char* e = sb_sql_error(db, SB_QUOTA_FULL, 13, 0);
2312
+ if (owned) { sqlite3_finalize(owned); sqlite3_exec(db, "ROLLBACK TO sb_d1_multi", 0, 0, 0); sqlite3_exec(db, "RELEASE sb_d1_multi", 0, 0, 0); } else sqlite3_reset(st);
2313
+ return e;
2254
2314
  }
2255
2315
  if (strict && bound != sqlite3_bind_parameter_count(st)) {
2256
- sqlite3_reset(st);
2257
- return sb_sql_error(db, "Wrong number of parameter bindings for SQL query.", 0, 0);
2316
+ char* e = sb_sql_error(db, "Wrong number of parameter bindings for SQL query.", 0, 0);
2317
+ if (owned) { sqlite3_finalize(owned); sqlite3_exec(db, "ROLLBACK TO sb_d1_multi", 0, 0, 0); sqlite3_exec(db, "RELEASE sb_d1_multi", 0, 0, 0); } else sqlite3_reset(st);
2318
+ return e;
2258
2319
  }
2259
2320
 
2260
2321
  int ncol = sqlite3_column_count(st);
@@ -2330,25 +2391,41 @@ static char* sb_sql_run_x(const char* path, const char* sql, const char* params,
2330
2391
  }
2331
2392
  // A read-only statement changed nothing: sqlite3_changes still holds the last write's count.
2332
2393
  if (native) {
2333
- cells->changes = sqlite3_stmt_readonly(st) ? 0 : sqlite3_changes(db);
2394
+ cells->changes = in_multi ? (int)(sqlite3_total_changes(db) - multi_before) : sqlite3_stmt_readonly(st) ? 0 : sqlite3_changes(db);
2334
2395
  cells->rowid = (long long)sqlite3_last_insert_rowid(db);
2335
2396
  } else {
2336
2397
  char tail[96];
2337
- int k = snprintf(tail, 96, "],\"changes\":%d,\"rowid\":%lld}", sqlite3_stmt_readonly(st) ? 0 : sqlite3_changes(db), (long long)sqlite3_last_insert_rowid(db));
2398
+ int k = snprintf(tail, 96, "],\"changes\":%d,\"rowid\":%lld}", in_multi ? (int)(sqlite3_total_changes(db) - multi_before) : sqlite3_stmt_readonly(st) ? 0 : sqlite3_changes(db), (long long)sqlite3_last_insert_rowid(db));
2338
2399
  sb_put(&b, tail, (size_t)k);
2339
2400
  }
2340
2401
  // Reset, don't finalize: the statement stays compiled in sb_stmt_cache. Reset
2341
2402
  // now (not just on next reuse) so a SELECT stops holding its read transaction.
2342
- sqlite3_reset(st);
2343
- if (cells && cells->used < 0) { free(b.p); sb_cells_free(cells); return sb_sql_error(db, "out of memory", 7, 0); }
2344
- // The statement's own error, kept before LTX capture runs: capture executes statements
2345
- // of its own, after which SQLite reports "not an error".
2403
+ // The statement's own error, kept before anything runs another statement (a rollback, LTX capture):
2404
+ // after those SQLite reports "not an error".
2346
2405
  char failure[512] = { 0 };
2347
2406
  int failure_code = 0;
2348
2407
  if (rc != SB_SQLITE_DONE && rc != SB_SQLITE_ROW) {
2349
2408
  snprintf(failure, sizeof(failure), "%s", sqlite3_errmsg(db));
2350
2409
  failure_code = sqlite3_extended_errcode(db);
2351
2410
  }
2411
+ sqlite3_reset(st);
2412
+ if (owned) {
2413
+ sqlite3_finalize(owned);
2414
+ if (rc == SB_SQLITE_DONE || rc == SB_SQLITE_ROW) {
2415
+ // a deferred constraint can fail here, as it does at COMMIT
2416
+ if (sqlite3_exec(db, "RELEASE sb_d1_multi", 0, 0, 0)) {
2417
+ snprintf(failure, sizeof(failure), "%s", sqlite3_errmsg(db));
2418
+ failure_code = sqlite3_extended_errcode(db);
2419
+ rc = 1;
2420
+ sqlite3_exec(db, "ROLLBACK TO sb_d1_multi", 0, 0, 0);
2421
+ sqlite3_exec(db, "RELEASE sb_d1_multi", 0, 0, 0);
2422
+ }
2423
+ } else {
2424
+ sqlite3_exec(db, "ROLLBACK TO sb_d1_multi", 0, 0, 0);
2425
+ sqlite3_exec(db, "RELEASE sb_d1_multi", 0, 0, 0);
2426
+ }
2427
+ }
2428
+ if (cells && cells->used < 0) { free(b.p); sb_cells_free(cells); return sb_sql_error(db, "out of memory", 7, 0); }
2352
2429
  const int capture_failed = sb_ltx_after(idx) != 0;
2353
2430
  if (capture_failed) rc = 10;
2354
2431
  if (rc != SB_SQLITE_DONE && rc != SB_SQLITE_ROW) {
@@ -4878,7 +4955,7 @@ function __sbEmbeddedDispatch(msg) {
4878
4955
  __sbD1Open(path, msg.strict === true);
4879
4956
  __sbD1Quota(path);
4880
4957
  if (msg.strict === true && Array.isArray(msg.lines)) {
4881
- __sbD1ExecLines(path, msg.lines);
4958
+ __sbD1ExecLines(path, msg.lines, msg.parity === true);
4882
4959
  return { ok: true };
4883
4960
  }
4884
4961
  const reply = JSON.parse(__sbSqlScriptRaw(path, String(msg.sql)));
@@ -4888,8 +4965,8 @@ function __sbEmbeddedDispatch(msg) {
4888
4965
  const strict = msg.strict === true;
4889
4966
  __sbD1Open(path, strict);
4890
4967
  __sbD1Quota(path);
4891
- if (op === "d1.batch") return { ok: true, results: __sbD1Batch(path, msg.statements || [], strict) };
4892
- const one = __sbD1Run(path, msg.sql, msg.params, strict);
4968
+ if (op === "d1.batch") return { ok: true, results: __sbD1Batch(path, msg.statements || [], strict, msg.parity === true) };
4969
+ const one = __sbD1Run(path, msg.sql, msg.params, strict, msg.parity === true);
4893
4970
  return { ok: true, results: one.results, columns: one.columns, rows: one.rows, native: one.native, meta: one.meta, success: true };
4894
4971
  }
4895
4972
 
@@ -5211,6 +5288,10 @@ function __sbEmbeddedDispatch(msg) {
5211
5288
 
5212
5289
  if (op === "queue.metrics") return { ok: true, metrics: __sbQueueBacklog(store, msg.queue) };
5213
5290
 
5291
+ // A consumer announces itself (the broker uses it to pick the delivery rules of the app's
5292
+ // compatibility date); the embedded store reads the date itself.
5293
+ if (op === "queue.consumer") return { ok: true };
5294
+
5214
5295
  if (op === "do.storage.get") {
5215
5296
  const r = __sbSql(store, "SELECT value FROM do_storage WHERE cls = ? AND id = ? AND key = ?", [
5216
5297
  msg.cls,
@@ -5436,10 +5517,10 @@ function __sbD1Bytes(cell) {
5436
5517
  for (let i = 0; i + 1 < hex.length; i += 2) out += String.fromCharCode(parseInt(hex.slice(i, i + 2), 16));
5437
5518
  return out;
5438
5519
  }
5439
- function __sbD1Run(path, sql, params, strict) {
5520
+ function __sbD1Run(path, sql, params, strict, multi) {
5440
5521
  if (strict) {
5441
5522
  // Rows come back as arrays, built in C (see __sbSqlRowsRaw); the header is small JSON.
5442
- const pair = __sbSqlRowsRaw(path, "/*sb-d1-strict*/" + String(sql), JSON.stringify(params || []));
5523
+ const pair = __sbSqlRowsRaw(path, multi ? "/*sb-d1-strict*//*sb-d1-multi*/" + String(sql) : "/*sb-d1-strict*/" + String(sql), JSON.stringify(params || []));
5443
5524
  if (pair[0] !== "") {
5444
5525
  const head = JSON.parse(pair[0]);
5445
5526
  if (head.ok === false) throw __sbD1Error(head);
@@ -5480,11 +5561,11 @@ function __sbD1Undo(path, savepoint) {
5480
5561
  return null;
5481
5562
  }
5482
5563
  // One transaction, like D1: a statement that fails undoes the ones before it.
5483
- function __sbD1Batch(path, list, strict) {
5564
+ function __sbD1Batch(path, list, strict, multi) {
5484
5565
  const results = [];
5485
5566
  __sbD1Control(path, "SAVEPOINT sb_d1_batch", strict);
5486
5567
  try {
5487
- for (let i = 0; i < list.length; i++) results.push(__sbD1Run(path, list[i].sql, list[i].params, strict));
5568
+ for (let i = 0; i < list.length; i++) results.push(__sbD1Run(path, list[i].sql, list[i].params, strict, multi));
5488
5569
  } catch (err) {
5489
5570
  __sbD1Undo(path, "sb_d1_batch");
5490
5571
  throw err;
@@ -5502,13 +5583,15 @@ function __sbD1Release(path, savepoint, strict) {
5502
5583
  throw strict ? __sbD1Error(r) : new Error("sqlite: " + r.error);
5503
5584
  }
5504
5585
  // exec() from 2026-10-24: line by line as workerd runs it, all or nothing.
5505
- function __sbD1ExecLines(path, lines) {
5586
+ function __sbD1ExecLines(path, lines, parity) {
5506
5587
  __sbD1Control(path, "SAVEPOINT sb_d1_exec", true);
5507
5588
  for (let i = 0; i < lines.length; i++) {
5508
5589
  const reply = JSON.parse(__sbSqlScriptRaw(path, String(lines[i])));
5509
5590
  if (reply.ok === false) {
5510
5591
  if (Number(reply.code) !== 10) __sbD1Undo(path, "sb_d1_exec");
5511
- throw new Error("D1_EXEC_ERROR: Error in line " + (i + 1) + ": " + lines[i] + ": " + reply.error);
5592
+ // parity (2026-11-03): D1's own wording after the line, " at offset N: SQLITE_ERROR" and all
5593
+ const detail = parity ? __sbD1Error(reply).message.slice(10) : reply.error;
5594
+ throw new Error("D1_EXEC_ERROR: Error in line " + (i + 1) + ": " + lines[i] + ": " + detail);
5512
5595
  }
5513
5596
  }
5514
5597
  __sbD1Release(path, "sb_d1_exec", true);
@@ -5618,7 +5701,7 @@ __sbD1QueryDirect = function (db, sql, params) {
5618
5701
  const path = __sbD1Path(String(db));
5619
5702
  __sbD1Open(path, true);
5620
5703
  __sbD1Quota(path);
5621
- return __sbD1Run(path, sql, params, true);
5704
+ return __sbD1Run(path, sql, params, true, __sbCompatDate() >= "2026-11-03");
5622
5705
  };
5623
5706
 
5624
5707
  // The same dispatch without the JSON text either side: D1 calls take this (see __sbRpcDirect).
@@ -5695,7 +5778,9 @@ function __sbQueueBacklog(store, queue) {
5695
5778
  }
5696
5779
 
5697
5780
  function __sbQueueAttemptCap(settings) {
5698
- return Math.max(5, (settings.maxRetries !== undefined ? Number(settings.maxRetries) : 3) + 1);
5781
+ const retries = (settings.maxRetries !== undefined ? Number(settings.maxRetries) : 3) + 1;
5782
+ // From 2026-11-05 a lost delivery counts as a retry, so there is no floor of 5.
5783
+ return __sbQueuesFixed() ? retries : Math.max(5, retries);
5699
5784
  }
5700
5785
 
5701
5786
  // How long a queue's due messages wait to fill a batch (max_batch_timeout), in ms.
@@ -5722,11 +5807,13 @@ async function __sbQueuePass(handlers, queues) {
5722
5807
  try {
5723
5808
  // A message whose deliveries never came back (the worker died or timed out each time)
5724
5809
  // stops after 5 attempts, or max_retries + 1 when that is more.
5725
- __sbSql(store, "UPDATE mq SET dead=1 WHERE queue=? AND visible_at<=? AND attempts>=?", [
5726
- name,
5727
- now,
5728
- __sbQueueAttemptCap(settings),
5729
- ]);
5810
+ if (__sbQueuesFixed()) __sbQueueRetire(store, name, settings, now);
5811
+ else
5812
+ __sbSql(store, "UPDATE mq SET dead=1 WHERE queue=? AND visible_at<=? AND attempts>=?", [
5813
+ name,
5814
+ now,
5815
+ __sbQueueAttemptCap(settings),
5816
+ ]);
5730
5817
  due = __sbSql(
5731
5818
  store,
5732
5819
  "SELECT id,body,attempts,sent_at,visible_at FROM mq WHERE queue=? AND dead=0 AND visible_at<=? ORDER BY visible_at LIMIT ?",
@@ -5763,13 +5850,45 @@ async function __sbQueuePass(handlers, queues) {
5763
5850
  if (p && __sbIsFn(p.then)) __sbTasks.push(p);
5764
5851
  },
5765
5852
  };
5766
- let result = __sbRunQueueBatch(handlers, { queue: name, messages, metadata }, ctx);
5767
- if (result && __sbIsFn(result.then)) result = await result;
5768
- if (__sbTasks.length) await __sbDrainWaitUntil(__sbTasks);
5853
+ // From 2026-11-05 a consumer that runs past the 30 s lease keeps its messages: the lease
5854
+ // is renewed while it runs, so they are not delivered a second time or lose their ack.
5855
+ const beat = __sbQueuesFixed()
5856
+ ? setInterval(() => {
5857
+ __sbSql(store, "UPDATE mq SET visible_at=? WHERE queue=? AND claim_token=? AND visible_at>?", [Date.now() + 30000, name, claim, Date.now()]);
5858
+ }, 10000)
5859
+ : null;
5860
+ let result;
5861
+ try {
5862
+ result = __sbRunQueueBatch(handlers, { queue: name, messages, metadata }, ctx);
5863
+ if (result && __sbIsFn(result.then)) result = await result;
5864
+ if (__sbTasks.length) await __sbDrainWaitUntil(__sbTasks);
5865
+ } finally {
5866
+ if (beat !== null) clearInterval(beat);
5867
+ }
5769
5868
  __sbQueueSettle(store, name, claim, result);
5770
5869
  }
5771
5870
  }
5772
5871
 
5872
+ // From 2026-11-05, the messages whose last allowed delivery never came back (the worker died or
5873
+ // timed out): they leave the queue as a message past max_retries does, into the dead letter
5874
+ // queue when there is one, or dropped. Runs inside the claim transaction.
5875
+ function __sbQueueRetire(store, name, settings, now) {
5876
+ const rows = __sbSql(store, "SELECT id FROM mq WHERE queue=? AND dead=0 AND visible_at<=? AND attempts>=?", [
5877
+ name,
5878
+ now,
5879
+ __sbQueueAttemptCap(settings),
5880
+ ]).rows;
5881
+ if (rows.length === 0) return;
5882
+ if (settings.deadLetterQueue === undefined) {
5883
+ __sbSql(store, "UPDATE mq SET dead=1 WHERE queue=? AND visible_at<=? AND attempts>=?", [name, now, __sbQueueAttemptCap(settings)]);
5884
+ return;
5885
+ }
5886
+ const part = __sbQueuePart(settings.deadLetterQueue);
5887
+ for (let i = 0; i < rows.length; i++)
5888
+ __sbSql(store, "UPDATE mq SET queue=?,visible_at=?,attempts=0,claim_token='',dead=0 WHERE queue=? AND id=?", [part, now, name, rows[i][0]]);
5889
+ if (__sbLocal !== null && __sbLocal.queues.indexOf(part) !== -1) __sbHostDue(now);
5890
+ }
5891
+
5773
5892
  // Applies a batch's outcome to the claimed rows. `dead` (past max_retries) move to the dead
5774
5893
  // letter queue `deadLetter` as new messages, or are marked dead; `delays` gives each retry's
5775
5894
  // delay in seconds (5 when the runtime sends none). A row whose claim expired meanwhile was
@@ -5863,7 +5982,9 @@ async function __sbAlarmPass() {
5863
5982
  try {
5864
5983
  const instance = __sbGetDOInstance(cls, id);
5865
5984
  if (__sbIsFn(instance.alarm)) {
5866
- const res = __sbDOGated() ? __sbDOEnter(instance.__sbGate, () => instance.alarm()) : instance.alarm();
5985
+ const res = __sbDOGated()
5986
+ ? __sbDOEnter(instance.__sbGate, () => __sbDOAlarmCall(instance, cls, id, due.rows[i][2], null))
5987
+ : __sbDOAlarmCall(instance, cls, id, due.rows[i][2], null);
5867
5988
  if (res && __sbIsFn(res.then)) await res;
5868
5989
  if (instance.__sbTasks.length) await __sbDrainWaitUntil(instance.__sbTasks);
5869
5990
  }
package/test/native-d1.ts CHANGED
@@ -373,6 +373,77 @@ int main(void) {
373
373
  (e: Error) => e.message === 'D1_EXEC_ERROR: Error in line 2: SELEC 2: near "SELEC": syntax error',
374
374
  );
375
375
  assert.equal(ops.__sbD1Run(ex, "SELECT count(*) AS n FROM x", [], false).results[0].n, 2, "exec rolled back");
376
+ // From 2026-11-03 (parity): D1's text, with the failing statement's own offset (counted from its first
377
+ // non-space character) and SQLite's result code.
378
+ const parityError = (lines: string[]) => {
379
+ try {
380
+ ops.__sbD1ExecLines(ex, lines, true);
381
+ } catch (e) {
382
+ return e instanceof Error ? e.message : String(e);
383
+ }
384
+ return "did not throw";
385
+ };
386
+ const parityCases: [string[], string][] = [
387
+ [
388
+ ["INSERT INTO x VALUES (3); SELEC 2"],
389
+ 'Error in line 1: INSERT INTO x VALUES (3); SELEC 2: near "SELEC": syntax error at offset 0: SQLITE_ERROR',
390
+ ],
391
+ [["SELEC 2"], 'Error in line 1: SELEC 2: near "SELEC": syntax error at offset 0: SQLITE_ERROR'],
392
+ [["SELECT 1;", " SELEC 3"], 'Error in line 2: SELEC 3: near "SELEC": syntax error at offset 2: SQLITE_ERROR'],
393
+ [["/*c*/ SELEC 1"], 'Error in line 1: /*c*/ SELEC 1: near "SELEC": syntax error at offset 6: SQLITE_ERROR'],
394
+ [["SELECT * FROM nope"], "Error in line 1: SELECT * FROM nope: no such table: nope: SQLITE_ERROR"],
395
+ [["SELECT 1,"], "Error in line 1: SELECT 1,: incomplete input: SQLITE_ERROR"],
396
+ ];
397
+ for (const [lines, text] of parityCases) assert.equal(parityError(lines), "D1_EXEC_ERROR: " + text);
398
+ ops.__sbD1ExecLines(ex, ["CREATE TABLE ux (a UNIQUE)", "INSERT INTO ux VALUES (1)"], true);
399
+ assert.equal(
400
+ parityError(["INSERT INTO ux VALUES (1)"]),
401
+ "D1_EXEC_ERROR: Error in line 1: INSERT INTO ux VALUES (1): UNIQUE constraint failed: ux.a: SQLITE_CONSTRAINT (extended: SQLITE_CONSTRAINT_UNIQUE)",
402
+ );
403
+ // From 2026-11-03 (multi): a statement with more behind it runs them all, in one savepoint, and answers with the last.
404
+ const mdb = join(work, "data/d1/MULTI.sqlite");
405
+ const multi = (sql: string, params: unknown[] = []) => ops.__sbD1Run(mdb, sql, params, true, true);
406
+ const multiError = (sql: string, params: unknown[] = []) => {
407
+ try {
408
+ multi(sql, params);
409
+ } catch (e) {
410
+ return e instanceof Error ? e.message : String(e);
411
+ }
412
+ return "did not throw";
413
+ };
414
+ multi("CREATE TABLE m (id INTEGER PRIMARY KEY, name TEXT, v TEXT UNIQUE)");
415
+ const two = multi("INSERT INTO m (id) VALUES (7); INSERT INTO m (id) VALUES (8)");
416
+ assert.deepEqual([two.meta.changes, two.meta.last_row_id, two.rows.length], [2, 8, 0]);
417
+ assert.equal(multi("INSERT INTO m (id) VALUES (10); UPDATE m SET name = 'x'").meta.changes, 4);
418
+ assert.deepEqual(multi("SELECT 1 AS a; SELECT 2 AS b").columns, ["b"]);
419
+ assert.deepEqual(multi("SELECT 1; SELECT ? AS p", [5]).rows, [[5]]);
420
+ assert.equal(multi("INSERT INTO m (id) VALUES (30); INSERT INTO m (id) VALUES (31); ").meta.changes, 2);
421
+ const mcount = () => ops.__sbD1Run(mdb, "SELECT count(*) FROM m", [], true, true).rows[0][0];
422
+ const mbefore = mcount();
423
+ assert.equal(
424
+ multiError("INSERT INTO m (id) VALUES (40); garbage"),
425
+ 'D1_ERROR: near "garbage": syntax error at offset 0: SQLITE_ERROR',
426
+ );
427
+ assert.match(
428
+ multiError("INSERT INTO m (id, v) VALUES (41, 'a'); INSERT INTO m (id, v) VALUES (42, 'a')"),
429
+ /^D1_ERROR: UNIQUE constraint failed: m\.v: SQLITE_CONSTRAINT/,
430
+ );
431
+ assert.equal(
432
+ multiError("INSERT INTO m (id) VALUES (?); INSERT INTO m (id) VALUES (?)", [50, 51]),
433
+ "D1_ERROR: When executing multiple SQL statements in a single call, only the last statement can have parameters.",
434
+ );
435
+ assert.equal(multiError("SELECT 1;;"), "D1_ERROR: SQL code did not contain a statement.");
436
+ assert.equal(multiError("SELECT 1; -- c"), "D1_ERROR: SQL code did not contain a statement.");
437
+ assert.equal(
438
+ multiError("SELECT 1; SELECT ? AS p", [1, 2]),
439
+ "D1_ERROR: Wrong number of parameter bindings for SQL query.",
440
+ );
441
+ assert.equal(mcount(), mbefore, "a failing script left nothing behind");
442
+ assert.equal(
443
+ ops.__sbD1Run(mdb, "INSERT INTO m (id) VALUES (60); INSERT INTO m (id) VALUES (61)", [], true, false).meta.changes,
444
+ 1,
445
+ "without multi only the first runs",
446
+ );
376
447
  const db2 = join(work, "data/d1/OPS.sqlite");
377
448
  ops.__sbD1Run(db2, "CREATE TABLE p (id INTEGER PRIMARY KEY)", [], false);
378
449
  ops.__sbD1Run(db2, "CREATE TABLE c (id INTEGER PRIMARY KEY, p INTEGER REFERENCES p(id))", [], false);