cursedbelt-server 4.37.0 → 4.39.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.
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The ctgr chunk store on a Cloudflare Worker: ONE Durable Object per multi-chunk payload, so
3
+ * every chunk of it meets every other chunk no matter which isolate answered it.
4
+ *
5
+ * ── Why this exists (2026-09-26, desk task 2185) ─────────────────────────────
6
+ * A GET-only device splits any body over 1800 bytes into several `/ctgr<xx>` GETs, and
7
+ * {@link mountCtgrTunnel} reassembles them in a {@link ChunkStore} — memory. On a Worker, memory
8
+ * is one isolate: chunk 2 answered by another isolate (another colo, a recycled isolate, load
9
+ * spread across several) finds no chunk 1, answers 202, and the payload is silently never
10
+ * delivered. desk's large saves were exposed; auth's login body is one chunk.
11
+ *
12
+ * ── The object is a rendezvous, not a decoder ────────────────────────────────
13
+ * It keeps each chunk's raw query params by index and, when the last one arrives, hands the
14
+ * whole ordered set back. The Worker then runs them through a fresh {@link ChunkStore} — the
15
+ * SAME crc/sha/size/decode path a Mac process uses — so nothing about verification is
16
+ * re-implemented here, and the object never parses a body. A single-chunk twin (the norm)
17
+ * never reaches the object at all.
18
+ *
19
+ * ── One object per payload ───────────────────────────────────────────────────
20
+ * Addressed by `ctgr:<_cid>` (a `crypto.randomUUID()` the client mints per request), so the
21
+ * chunks of one request are serialised through one object and different requests never
22
+ * contend. The partial lives in the object's memory: chunks arrive milliseconds apart, the idle
23
+ * TTL is 30 s, and an object is only evicted after minutes idle — a partial it loses is one a
24
+ * deploy would have lost too. An object holding nothing costs nothing.
25
+ *
26
+ * ── Types are structural ────────────────────────────────────────────────────
27
+ * No `@cloudflare/workers-types`, and the classic `fetch` interface — the same reasons as
28
+ * `loginThrottleDurable.ts`.
29
+ */
30
+ import { type ChunkStoreOpts } from 'cursedbelt-core/ctgr/chunk-store';
31
+ import type { CtgrChunkSink } from './ctgrTunnel.js';
32
+ /** The members of a `DurableObjectNamespace` binding the store calls. */
33
+ export interface CtgrChunkNamespaceLike {
34
+ idFromName(name: string): unknown;
35
+ get(id: unknown): {
36
+ fetch(request: Request): Promise<Response>;
37
+ };
38
+ }
39
+ /**
40
+ * The Durable Object. Re-export it from the Worker's entry under the `class_name` your
41
+ * `wrangler.jsonc` binds, and add it in a migration (`new_sqlite_classes`):
42
+ *
43
+ * export { CtgrChunkObject as DeskCtgrChunks } from "cursedbelt-server/ctgr-tunnel/durable";
44
+ */
45
+ export declare class CtgrChunkObject {
46
+ private partial;
47
+ private readonly maxPayloadBytes;
48
+ private readonly ttlMs;
49
+ private readonly now;
50
+ /** `options` is for tests; the runtime passes `(state, env)` only. */
51
+ constructor(_state?: unknown, _env?: unknown, options?: ChunkStoreOpts);
52
+ fetch(request: Request): Promise<Response>;
53
+ private add;
54
+ }
55
+ /**
56
+ * The Worker half: a {@link CtgrChunkSink} for {@link mountCtgrTunnel} whose multi-chunk
57
+ * partials live in the object. Cheap to build — build it per request over `env.<BINDING>`.
58
+ *
59
+ * An object that cannot be reached THROWS, and the tunnel answers 503: a payload half-held in
60
+ * one isolate's memory is exactly the silent loss this exists to end, so there is no fallback.
61
+ */
62
+ export declare function createDurableChunkStore(namespace: CtgrChunkNamespaceLike, options?: ChunkStoreOpts): CtgrChunkSink;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The ctgr chunk store on a Cloudflare Worker: ONE Durable Object per multi-chunk payload, so
3
+ * every chunk of it meets every other chunk no matter which isolate answered it.
4
+ *
5
+ * ── Why this exists (2026-09-26, desk task 2185) ─────────────────────────────
6
+ * A GET-only device splits any body over 1800 bytes into several `/ctgr<xx>` GETs, and
7
+ * {@link mountCtgrTunnel} reassembles them in a {@link ChunkStore} — memory. On a Worker, memory
8
+ * is one isolate: chunk 2 answered by another isolate (another colo, a recycled isolate, load
9
+ * spread across several) finds no chunk 1, answers 202, and the payload is silently never
10
+ * delivered. desk's large saves were exposed; auth's login body is one chunk.
11
+ *
12
+ * ── The object is a rendezvous, not a decoder ────────────────────────────────
13
+ * It keeps each chunk's raw query params by index and, when the last one arrives, hands the
14
+ * whole ordered set back. The Worker then runs them through a fresh {@link ChunkStore} — the
15
+ * SAME crc/sha/size/decode path a Mac process uses — so nothing about verification is
16
+ * re-implemented here, and the object never parses a body. A single-chunk twin (the norm)
17
+ * never reaches the object at all.
18
+ *
19
+ * ── One object per payload ───────────────────────────────────────────────────
20
+ * Addressed by `ctgr:<_cid>` (a `crypto.randomUUID()` the client mints per request), so the
21
+ * chunks of one request are serialised through one object and different requests never
22
+ * contend. The partial lives in the object's memory: chunks arrive milliseconds apart, the idle
23
+ * TTL is 30 s, and an object is only evicted after minutes idle — a partial it loses is one a
24
+ * deploy would have lost too. An object holding nothing costs nothing.
25
+ *
26
+ * ── Types are structural ────────────────────────────────────────────────────
27
+ * No `@cloudflare/workers-types`, and the classic `fetch` interface — the same reasons as
28
+ * `loginThrottleDurable.ts`.
29
+ */
30
+ import { ChunkStore } from 'cursedbelt-core/ctgr/chunk-store';
31
+ import { parseChunk } from 'cursedbelt-core/ctgr';
32
+ const DEFAULT_MAX_PAYLOAD_BYTES = 10 * 1024 * 1024;
33
+ const DEFAULT_TTL_MS = 30_000;
34
+ const json = (value, status = 200) => new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json' } });
35
+ /**
36
+ * The Durable Object. Re-export it from the Worker's entry under the `class_name` your
37
+ * `wrangler.jsonc` binds, and add it in a migration (`new_sqlite_classes`):
38
+ *
39
+ * export { CtgrChunkObject as DeskCtgrChunks } from "cursedbelt-server/ctgr-tunnel/durable";
40
+ */
41
+ export class CtgrChunkObject {
42
+ partial = null;
43
+ maxPayloadBytes;
44
+ ttlMs;
45
+ now;
46
+ /** `options` is for tests; the runtime passes `(state, env)` only. */
47
+ constructor(_state, _env, options = {}) {
48
+ this.maxPayloadBytes = options.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES;
49
+ this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
50
+ this.now = options.now ?? Date.now;
51
+ }
52
+ async fetch(request) {
53
+ const path = new URL(request.url).pathname;
54
+ if (request.method !== 'POST' || path !== '/ctgr/chunk') {
55
+ return json({ error: `${request.method} ${path}: only POST /ctgr/chunk` }, 404);
56
+ }
57
+ let params;
58
+ let chunk;
59
+ try {
60
+ params = (await request.json());
61
+ chunk = parseChunk(params);
62
+ }
63
+ catch (error) {
64
+ return json({ error: error instanceof Error ? error.message : String(error) }, 400);
65
+ }
66
+ return json(this.add(params, chunk));
67
+ }
68
+ add(params, chunk) {
69
+ if (this.partial && this.partial.updatedAt < this.now() - this.ttlMs)
70
+ this.partial = null;
71
+ if (chunk.payloadLength > this.maxPayloadBytes)
72
+ return { error: 'ctgr: payload exceeds size cap' };
73
+ let entry = this.partial;
74
+ if (!entry) {
75
+ entry = {
76
+ cid: chunk.cid,
77
+ total: chunk.total,
78
+ payloadLength: chunk.payloadLength,
79
+ gzip: chunk.gzip,
80
+ method: chunk.method,
81
+ parts: new Array(chunk.total),
82
+ received: 0,
83
+ bytes: 0,
84
+ updatedAt: this.now(),
85
+ };
86
+ this.partial = entry;
87
+ }
88
+ else if (entry.cid !== chunk.cid ||
89
+ entry.total !== chunk.total ||
90
+ entry.payloadLength !== chunk.payloadLength ||
91
+ entry.gzip !== chunk.gzip ||
92
+ entry.method !== chunk.method) {
93
+ // A payload's framing must not change mid-stream — the same rule `ChunkStore` keeps.
94
+ this.partial = null;
95
+ return { error: 'ctgr: inconsistent chunk metadata' };
96
+ }
97
+ entry.updatedAt = this.now();
98
+ if (entry.parts[chunk.index] === undefined) {
99
+ entry.parts[chunk.index] = params;
100
+ entry.received += 1;
101
+ entry.bytes += chunk.bytes.length;
102
+ if (entry.bytes > this.maxPayloadBytes) {
103
+ this.partial = null;
104
+ return { error: 'ctgr: payload exceeds size cap' };
105
+ }
106
+ }
107
+ if (entry.received < entry.total)
108
+ return { pending: true };
109
+ this.partial = null;
110
+ return { chunks: entry.parts };
111
+ }
112
+ }
113
+ /**
114
+ * The Worker half: a {@link CtgrChunkSink} for {@link mountCtgrTunnel} whose multi-chunk
115
+ * partials live in the object. Cheap to build — build it per request over `env.<BINDING>`.
116
+ *
117
+ * An object that cannot be reached THROWS, and the tunnel answers 503: a payload half-held in
118
+ * one isolate's memory is exactly the silent loss this exists to end, so there is no fallback.
119
+ */
120
+ export function createDurableChunkStore(namespace, options = {}) {
121
+ return {
122
+ async add(input) {
123
+ const params = input instanceof URLSearchParams ? Object.fromEntries(input) : { ...input };
124
+ let chunk;
125
+ try {
126
+ chunk = parseChunk(params);
127
+ }
128
+ catch (error) {
129
+ return error instanceof Error ? error : new Error(String(error));
130
+ }
131
+ // One chunk needs no rendezvous — decode it here, in this isolate.
132
+ if (chunk.total === 1)
133
+ return new ChunkStore(options).add(params);
134
+ const stub = namespace.get(namespace.idFromName(`ctgr:${chunk.cid}`));
135
+ const response = await stub.fetch(new Request('https://ctgr-chunks.invalid/ctgr/chunk', {
136
+ method: 'POST',
137
+ headers: { 'content-type': 'application/json' },
138
+ body: JSON.stringify(params),
139
+ }));
140
+ const reply = (await response.json().catch(() => null));
141
+ if (!reply)
142
+ throw new Error(`ctgr chunk object answered ${response.status} with no JSON`);
143
+ if ('error' in reply)
144
+ return new Error(reply.error);
145
+ if ('pending' in reply)
146
+ return false;
147
+ // Every chunk is here: the one verify/decode path, in a store that lives for this call.
148
+ const assemble = new ChunkStore(options);
149
+ let result = new Error('ctgr: empty chunk set');
150
+ for (const part of reply.chunks)
151
+ result = await assemble.add(part);
152
+ return result;
153
+ },
154
+ };
155
+ }
@@ -1,15 +1,27 @@
1
- import { ChunkStore } from 'cursedbelt-core/ctgr/chunk-store';
1
+ import { type CtgrDecoded } from 'cursedbelt-core/ctgr/types';
2
2
  import type { Env, Hono } from 'hono';
3
+ /**
4
+ * What the tunnel needs of an accumulator: a {@link ChunkStore}, or the Worker's
5
+ * `createDurableChunkStore` (`cursedbelt-server/ctgr-tunnel/durable`), whose partials every
6
+ * isolate shares. `false` is an intermediate chunk (→ 202), an `Error` a bad payload (→ 400);
7
+ * a THROW means the store itself could not answer (→ 503).
8
+ */
9
+ export interface CtgrChunkSink {
10
+ add(params: Record<string, string> | URLSearchParams): Promise<CtgrDecoded | false | Error>;
11
+ }
3
12
  export interface CtgrTunnelOptions {
4
13
  /**
5
14
  * The accumulator for multi-chunk payloads. Share ONE across an app (the default is
6
15
  * a fresh singleton); pass your own to tune the DoS caps ({@link ChunkStore}).
16
+ * 🔴 On a Cloudflare Worker a module-scope `ChunkStore` is one ISOLATE's memory — pass
17
+ * `createDurableChunkStore(env.<BINDING>)` there, or a multi-chunk payload whose chunks
18
+ * land on different isolates is silently never delivered.
7
19
  */
8
- store?: ChunkStore;
20
+ store?: CtgrChunkSink;
9
21
  }
10
22
  /**
11
23
  * Mount the GET-only tunnel on `app`. Call once, on the Hono app whose real routes sit
12
24
  * at the paths the twins mirror (a twin path is `/ctgr<xx>` + the real path). Returns
13
- * the {@link ChunkStore} in use, for tests and metrics.
25
+ * the store in use, for tests and metrics.
14
26
  */
15
- export declare function mountCtgrTunnel<E extends Env = Env>(app: Hono<E>, options?: CtgrTunnelOptions): ChunkStore;
27
+ export declare function mountCtgrTunnel<E extends Env = Env>(app: Hono<E>, options?: CtgrTunnelOptions): CtgrChunkSink;
@@ -23,7 +23,7 @@ const isCtgrParam = (key) => key === '_d' || key === '_sha' || key.startsWith('_
23
23
  /**
24
24
  * Mount the GET-only tunnel on `app`. Call once, on the Hono app whose real routes sit
25
25
  * at the paths the twins mirror (a twin path is `/ctgr<xx>` + the real path). Returns
26
- * the {@link ChunkStore} in use, for tests and metrics.
26
+ * the store in use, for tests and metrics.
27
27
  */
28
28
  export function mountCtgrTunnel(app, options = {}) {
29
29
  const store = options.store ?? new ChunkStore();
@@ -36,7 +36,14 @@ export function mountCtgrTunnel(app, options = {}) {
36
36
  if (!pathMethod) {
37
37
  return c.json({ error: 'not found', code: 'NOT_FOUND' }, 404);
38
38
  }
39
- const result = params._cv === undefined ? legacy.add(params, pathMethod) : await store.add(params);
39
+ let result;
40
+ try {
41
+ result = params._cv === undefined ? legacy.add(params, pathMethod) : await store.add(params);
42
+ }
43
+ catch (error) {
44
+ console.error(`[ctgr] the chunk store could not answer: ${error instanceof Error ? error.message : String(error)}`);
45
+ return c.json({ error: 'ctgr: the chunk store could not answer — send it again', code: 'CTGR_STORE_UNAVAILABLE' }, 503);
46
+ }
40
47
  if (result === false)
41
48
  return c.json({ acknowledged: true }, 202);
42
49
  if (result instanceof Error) {
@@ -10,6 +10,13 @@
10
10
  * · **100 bound parameters per query.** Any seed, import or bulk insert hits this. Use
11
11
  * {@link chunkForBind}, which does the arithmetic rather than leaving it to a guess.
12
12
  * · **2 MB per string / BLOB / row.** Nothing may store a media byte in D1.
13
+ * · **5 terms per compound SELECT** — `UNION`, `UNION ALL`, `INTERSECT`, `EXCEPT`, and
14
+ * every term of a recursive CTE counts. SQLite's own default is 500, so a sixth term is
15
+ * green here and `too many terms in compound SELECT` on D1. Measured against production
16
+ * D1 on 2026-09-26: five terms answer, six are refused, inside a CTE exactly as outside
17
+ * one. `family`'s "Both families" viewport was a six-term recursive CTE, and every read
18
+ * it scoped failed in production from the D1 cutover until then. Wrap extra seed terms in
19
+ * a subquery — `SELECT id FROM (… UNION …)` is ONE term of the outer compound.
13
20
  *
14
21
  * The limits are asserted by the LOCAL driver as well as the remote one, which is the
15
22
  * whole point: a bulk insert that would fail in production fails on the laptop, at the
@@ -31,12 +38,29 @@ export declare const LIMITS: {
31
38
  readonly columnsPerTable: 100;
32
39
  /** Wall-clock milliseconds one query may take. */
33
40
  readonly queryDurationMs: 30000;
41
+ /** Terms in one compound SELECT, recursive CTEs included. SQLite's default is 500. */
42
+ readonly compoundSelectTerms: 5;
34
43
  };
35
44
  /**
36
45
  * Refuse a statement that D1 would refuse — checked on BOTH drivers, at `bind()` time, so
37
46
  * the failure lands on the call site that built the query rather than in production.
38
47
  */
39
48
  export declare function assertWithinLimits(sql: string, params: readonly D1LikeBindable[]): void;
49
+ /**
50
+ * The most terms any ONE compound SELECT in `sql` has — `1` for a plain SELECT.
51
+ *
52
+ * Lexical, because `bun:sqlite` exposes no `sqlite3_limit`: string literals, quoted
53
+ * identifiers and comments are skipped, and every parenthesis opens its own count, since a
54
+ * parenthesised subquery or CTE body is a compound of its own. A `;` ends a statement.
55
+ * Multi-row `VALUES` is not counted — SQLite stopped counting it against this limit in 3.8.8.
56
+ */
57
+ export declare function maxCompoundSelectTerms(sql: string): number;
58
+ /**
59
+ * Refuse a statement whose compound SELECT has more terms than D1 allows — see
60
+ * `LIMITS.compoundSelectTerms`. Called at `prepare()`, because a statement with nothing to
61
+ * bind never reaches {@link assertWithinLimits}.
62
+ */
63
+ export declare function assertCompoundSelectTerms(sql: string): void;
40
64
  /** Refuse a `batch()` bigger than one Worker invocation may issue. */
41
65
  export declare function assertBatchSize(count: number): void;
42
66
  /**
@@ -10,6 +10,13 @@
10
10
  * · **100 bound parameters per query.** Any seed, import or bulk insert hits this. Use
11
11
  * {@link chunkForBind}, which does the arithmetic rather than leaving it to a guess.
12
12
  * · **2 MB per string / BLOB / row.** Nothing may store a media byte in D1.
13
+ * · **5 terms per compound SELECT** — `UNION`, `UNION ALL`, `INTERSECT`, `EXCEPT`, and
14
+ * every term of a recursive CTE counts. SQLite's own default is 500, so a sixth term is
15
+ * green here and `too many terms in compound SELECT` on D1. Measured against production
16
+ * D1 on 2026-09-26: five terms answer, six are refused, inside a CTE exactly as outside
17
+ * one. `family`'s "Both families" viewport was a six-term recursive CTE, and every read
18
+ * it scoped failed in production from the D1 cutover until then. Wrap extra seed terms in
19
+ * a subquery — `SELECT id FROM (… UNION …)` is ONE term of the outer compound.
13
20
  *
14
21
  * The limits are asserted by the LOCAL driver as well as the remote one, which is the
15
22
  * whole point: a bulk insert that would fail in production fails on the laptop, at the
@@ -31,6 +38,8 @@ export const LIMITS = {
31
38
  columnsPerTable: 100,
32
39
  /** Wall-clock milliseconds one query may take. */
33
40
  queryDurationMs: 30_000,
41
+ /** Terms in one compound SELECT, recursive CTEs included. SQLite's default is 500. */
42
+ compoundSelectTerms: 5,
34
43
  };
35
44
  const byteLength = (v) => {
36
45
  if (typeof v === 'string')
@@ -61,6 +70,91 @@ export function assertWithinLimits(sql, params) {
61
70
  }
62
71
  }
63
72
  }
73
+ /**
74
+ * The most terms any ONE compound SELECT in `sql` has — `1` for a plain SELECT.
75
+ *
76
+ * Lexical, because `bun:sqlite` exposes no `sqlite3_limit`: string literals, quoted
77
+ * identifiers and comments are skipped, and every parenthesis opens its own count, since a
78
+ * parenthesised subquery or CTE body is a compound of its own. A `;` ends a statement.
79
+ * Multi-row `VALUES` is not counted — SQLite stopped counting it against this limit in 3.8.8.
80
+ */
81
+ export function maxCompoundSelectTerms(sql) {
82
+ const stack = [0];
83
+ let max = 0;
84
+ let i = 0;
85
+ const n = sql.length;
86
+ const skipQuoted = (close) => {
87
+ i++;
88
+ while (i < n) {
89
+ if (sql[i] === close) {
90
+ // A doubled quote is an escaped one, inside the same literal.
91
+ if (sql[i + 1] === close && close !== ']') {
92
+ i += 2;
93
+ continue;
94
+ }
95
+ break;
96
+ }
97
+ i++;
98
+ }
99
+ i++;
100
+ };
101
+ while (i < n) {
102
+ const c = sql[i];
103
+ if (c === "'" || c === '"' || c === '`')
104
+ skipQuoted(c);
105
+ else if (c === '[')
106
+ skipQuoted(']');
107
+ else if (c === '-' && sql[i + 1] === '-') {
108
+ while (i < n && sql[i] !== '\n')
109
+ i++;
110
+ }
111
+ else if (c === '/' && sql[i + 1] === '*') {
112
+ const end = sql.indexOf('*/', i + 2);
113
+ i = end === -1 ? n : end + 2;
114
+ }
115
+ else if (c === '(') {
116
+ stack.push(0);
117
+ i++;
118
+ }
119
+ else if (c === ')') {
120
+ if (stack.length > 1)
121
+ stack.pop();
122
+ i++;
123
+ }
124
+ else if (c === ';') {
125
+ stack.length = 1;
126
+ stack[0] = 0;
127
+ i++;
128
+ }
129
+ else if (/[A-Za-z_]/.test(c)) {
130
+ let j = i + 1;
131
+ while (j < n && /[A-Za-z0-9_$]/.test(sql[j]))
132
+ j++;
133
+ const word = sql.slice(i, j).toUpperCase();
134
+ if (word === 'UNION' || word === 'INTERSECT' || word === 'EXCEPT') {
135
+ const top = stack.length - 1;
136
+ stack[top] = stack[top] + 1;
137
+ max = Math.max(max, stack[top]);
138
+ }
139
+ i = j;
140
+ }
141
+ else
142
+ i++;
143
+ }
144
+ return max + 1;
145
+ }
146
+ /**
147
+ * Refuse a statement whose compound SELECT has more terms than D1 allows — see
148
+ * `LIMITS.compoundSelectTerms`. Called at `prepare()`, because a statement with nothing to
149
+ * bind never reaches {@link assertWithinLimits}.
150
+ */
151
+ export function assertCompoundSelectTerms(sql) {
152
+ const terms = maxCompoundSelectTerms(sql);
153
+ if (terms > LIMITS.compoundSelectTerms) {
154
+ throw new D1LimitError('terms in one compound SELECT', terms, LIMITS.compoundSelectTerms, 'D1 answers `too many terms in compound SELECT`. Fold extra terms into a subquery — ' +
155
+ '`SELECT id FROM (a UNION b UNION c)` is one term of the outer compound.');
156
+ }
157
+ }
64
158
  /** Refuse a `batch()` bigger than one Worker invocation may issue. */
65
159
  export function assertBatchSize(count) {
66
160
  if (count > LIMITS.queriesPerInvocation) {
@@ -13,7 +13,7 @@
13
13
  * refused here exactly as it is refused remotely. The point of a local driver is to fail
14
14
  * the same way, early and on a laptop.
15
15
  */
16
- import { assertBatchSize, assertWithinLimits } from './limits.js';
16
+ import { assertBatchSize, assertCompoundSelectTerms, assertWithinLimits } from './limits.js';
17
17
  import { D1UnsupportedError, } from './types.js';
18
18
  import { isPureReadSql, makeMeta, normalizeBinds, normalizeRows, normalizeValue } from './values.js';
19
19
  /** Shared clock, so `meta.duration` means the same thing on both sides of the seam. */
@@ -133,6 +133,7 @@ export function createLocalD1(db) {
133
133
  return {
134
134
  flavor: 'local',
135
135
  prepare(sql) {
136
+ assertCompoundSelectTerms(sql);
136
137
  return new LocalStatement(db, sql);
137
138
  },
138
139
  /**
@@ -14,7 +14,7 @@
14
14
  * a real binding satisfies it without a cast, and the test suite can satisfy it with a
15
15
  * fake that has no `workerd` in sight.
16
16
  */
17
- import { assertBatchSize, assertWithinLimits } from './limits.js';
17
+ import { assertBatchSize, assertCompoundSelectTerms, assertWithinLimits } from './limits.js';
18
18
  import { D1UnsupportedError, } from './types.js';
19
19
  import { makeMeta, normalizeBinds, normalizeRows, normalizeValue } from './values.js';
20
20
  /**
@@ -91,6 +91,7 @@ export function createRemoteD1(binding) {
91
91
  return {
92
92
  flavor: 'd1',
93
93
  prepare(sql) {
94
+ assertCompoundSelectTerms(sql);
94
95
  return new RemoteStatement(binding.prepare(sql), sql);
95
96
  },
96
97
  async batch(statements) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.37.0",
3
+ "version": "4.39.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
@@ -65,6 +65,12 @@
65
65
  "source": "./src/server/ctgrTunnel.ts",
66
66
  "import": "./dist/server/ctgrTunnel.js"
67
67
  },
68
+ "./ctgr-tunnel/durable": {
69
+ "types": "./dist/server/ctgrChunkDurable.d.ts",
70
+ "bun": "./src/server/ctgrChunkDurable.ts",
71
+ "source": "./src/server/ctgrChunkDurable.ts",
72
+ "import": "./dist/server/ctgrChunkDurable.js"
73
+ },
68
74
  "./analytics": {
69
75
  "types": "./dist/server/analytics/index.d.ts",
70
76
  "bun": "./src/server/analytics/index.ts",
@@ -0,0 +1,144 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { encodeRequest } from 'cursedbelt-core/ctgr';
3
+ import { ChunkStore } from 'cursedbelt-core/ctgr/chunk-store';
4
+ import { Hono } from 'hono';
5
+ import { type CtgrChunkNamespaceLike, CtgrChunkObject, createDurableChunkStore } from './ctgrChunkDurable.js';
6
+ import { mountCtgrTunnel } from './ctgrTunnel.js';
7
+
8
+ /** A namespace like the runtime's: one object per name, however many isolates ask. */
9
+ function fakeNamespace(clock?: () => number) {
10
+ const objects = new Map<string, CtgrChunkObject>();
11
+ const calls: string[] = [];
12
+ const namespace: CtgrChunkNamespaceLike = {
13
+ idFromName: (name) => name,
14
+ get: (id) => ({
15
+ fetch: async (request: Request) => {
16
+ const name = id as string;
17
+ calls.push(name);
18
+ let object = objects.get(name);
19
+ if (!object) {
20
+ object = new CtgrChunkObject(undefined, undefined, clock ? { now: clock } : {});
21
+ objects.set(name, object);
22
+ }
23
+ return object.fetch(request);
24
+ },
25
+ }),
26
+ };
27
+ return { namespace, objects, calls };
28
+ }
29
+
30
+ const bigBody = { note: 'x'.repeat(9000), n: 7 };
31
+
32
+ async function multiChunk() {
33
+ // Incompressible enough to stay multi-chunk after gzip.
34
+ const random = Array.from({ length: 6000 }, (_, i) => ((i * 2654435761) >>> 0).toString(36)).join('');
35
+ const { chunks } = await encodeRequest('POST', '/api/v1/notes', { ...bigBody, random });
36
+ expect(chunks.length).toBeGreaterThan(2);
37
+ return { chunks, body: { ...bigBody, random } };
38
+ }
39
+
40
+ describe('createDurableChunkStore — two isolates, one object', () => {
41
+ test('chunks alternating between two isolates reassemble, in any order', async () => {
42
+ const { namespace } = fakeNamespace();
43
+ const isolates = [createDurableChunkStore(namespace), createDurableChunkStore(namespace)];
44
+ const { chunks, body } = await multiChunk();
45
+ const order = chunks.map((_, i) => i).reverse();
46
+ const results = [];
47
+ for (const [n, i] of order.entries()) results.push(await isolates[n % 2].add(chunks[i].params));
48
+ for (const intermediate of results.slice(0, -1)) expect(intermediate).toBe(false);
49
+ const last = results.at(-1);
50
+ expect(last).not.toBeInstanceOf(Error);
51
+ expect(last && !(last instanceof Error) ? last.body : null).toEqual(body);
52
+ });
53
+
54
+ test('🔴 the failure it replaces: two per-isolate ChunkStores never finish the payload', async () => {
55
+ const isolates = [new ChunkStore(), new ChunkStore()];
56
+ const { chunks } = await multiChunk();
57
+ const results = [];
58
+ for (const [n, chunk] of chunks.entries()) results.push(await isolates[n % 2].add(chunk.params));
59
+ expect(results.every((r) => r === false)).toBe(true);
60
+ });
61
+
62
+ test('a single-chunk twin never reaches an object', async () => {
63
+ const { namespace, calls } = fakeNamespace();
64
+ const { chunks } = await encodeRequest('POST', '/api/v1/notes', { small: true });
65
+ expect(chunks).toHaveLength(1);
66
+ const result = await createDurableChunkStore(namespace).add(chunks[0].params);
67
+ expect(result && !(result instanceof Error) ? result.body : null).toEqual({ small: true });
68
+ expect(calls).toHaveLength(0);
69
+ });
70
+
71
+ test('each payload has its own object, and a finished one holds nothing', async () => {
72
+ const { namespace, calls } = fakeNamespace();
73
+ const store = createDurableChunkStore(namespace);
74
+ const a = await multiChunk();
75
+ for (const chunk of a.chunks) await store.add(chunk.params);
76
+ expect(new Set(calls)).toEqual(new Set([`ctgr:${a.chunks[0].params._cid}`]));
77
+ // Replaying the first chunk starts a fresh partial rather than re-finishing the old one.
78
+ expect(await store.add(a.chunks[0].params)).toBe(false);
79
+ });
80
+
81
+ test('a corrupted chunk is a 4xx Error in the isolate, before any object is asked', async () => {
82
+ const { namespace, calls } = fakeNamespace();
83
+ const { chunks } = await multiChunk();
84
+ const bad = { ...chunks[1].params, _d: `${chunks[1].params._d.slice(0, -4)}AAAA` };
85
+ expect(await createDurableChunkStore(namespace).add(bad)).toBeInstanceOf(Error);
86
+ expect(calls).toHaveLength(0);
87
+ });
88
+
89
+ test('framing that changes mid-payload is refused, as ChunkStore refuses it', async () => {
90
+ const { namespace } = fakeNamespace();
91
+ const store = createDurableChunkStore(namespace);
92
+ const { chunks } = await multiChunk();
93
+ const other = await encodeRequest('PATCH', '/api/v1/notes/1', (await multiChunk()).body);
94
+ expect(await store.add(chunks[0].params)).toBe(false);
95
+ const forged = { ...other.chunks[1].params, _cid: chunks[0].params._cid };
96
+ expect(await store.add(forged)).toBeInstanceOf(Error);
97
+ });
98
+
99
+ test('a partial idle past the TTL is dropped, so a late chunk cannot complete it', async () => {
100
+ let now = 0;
101
+ const { namespace } = fakeNamespace(() => now);
102
+ const store = createDurableChunkStore(namespace);
103
+ const { chunks } = await multiChunk();
104
+ for (const chunk of chunks.slice(0, -1)) expect(await store.add(chunk.params)).toBe(false);
105
+ now = 31_000;
106
+ expect(await store.add(chunks.at(-1)!.params)).toBe(false);
107
+ });
108
+
109
+ test('an object that cannot answer is a THROW — the tunnel turns it into a 503', async () => {
110
+ const down: CtgrChunkNamespaceLike = {
111
+ idFromName: (name) => name,
112
+ get: () => ({ fetch: async () => new Response('Internal error', { status: 500 }) }),
113
+ };
114
+ const app = new Hono();
115
+ mountCtgrTunnel(app, { store: createDurableChunkStore(down) });
116
+ app.post('/api/v1/notes', (c) => c.json({ reached: true }));
117
+ const { chunks } = await multiChunk();
118
+ const res = await app.fetch(new Request(`http://localhost${chunks[0].url}`));
119
+ expect(res.status).toBe(503);
120
+ expect(((await res.json()) as { code: string }).code).toBe('CTGR_STORE_UNAVAILABLE');
121
+ });
122
+
123
+ test('the whole tunnel over two isolates: every handler reached once, with the body', async () => {
124
+ const { namespace } = fakeNamespace();
125
+ const reached: unknown[] = [];
126
+ const isolate = () => {
127
+ const app = new Hono();
128
+ mountCtgrTunnel(app, { store: createDurableChunkStore(namespace) });
129
+ app.post('/api/v1/notes', async (c) => {
130
+ reached.push(await c.req.json());
131
+ return c.json({ ok: true }, 201);
132
+ });
133
+ return app;
134
+ };
135
+ const apps = [isolate(), isolate()];
136
+ const { chunks, body } = await multiChunk();
137
+ const statuses: number[] = [];
138
+ for (const [n, chunk] of chunks.entries()) {
139
+ statuses.push((await apps[n % 2].fetch(new Request(`http://localhost${chunk.url}`))).status);
140
+ }
141
+ expect(statuses).toEqual([...chunks.slice(1).map(() => 202), 201]);
142
+ expect(reached).toEqual([body]);
143
+ });
144
+ });
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The ctgr chunk store on a Cloudflare Worker: ONE Durable Object per multi-chunk payload, so
3
+ * every chunk of it meets every other chunk no matter which isolate answered it.
4
+ *
5
+ * ── Why this exists (2026-09-26, desk task 2185) ─────────────────────────────
6
+ * A GET-only device splits any body over 1800 bytes into several `/ctgr<xx>` GETs, and
7
+ * {@link mountCtgrTunnel} reassembles them in a {@link ChunkStore} — memory. On a Worker, memory
8
+ * is one isolate: chunk 2 answered by another isolate (another colo, a recycled isolate, load
9
+ * spread across several) finds no chunk 1, answers 202, and the payload is silently never
10
+ * delivered. desk's large saves were exposed; auth's login body is one chunk.
11
+ *
12
+ * ── The object is a rendezvous, not a decoder ────────────────────────────────
13
+ * It keeps each chunk's raw query params by index and, when the last one arrives, hands the
14
+ * whole ordered set back. The Worker then runs them through a fresh {@link ChunkStore} — the
15
+ * SAME crc/sha/size/decode path a Mac process uses — so nothing about verification is
16
+ * re-implemented here, and the object never parses a body. A single-chunk twin (the norm)
17
+ * never reaches the object at all.
18
+ *
19
+ * ── One object per payload ───────────────────────────────────────────────────
20
+ * Addressed by `ctgr:<_cid>` (a `crypto.randomUUID()` the client mints per request), so the
21
+ * chunks of one request are serialised through one object and different requests never
22
+ * contend. The partial lives in the object's memory: chunks arrive milliseconds apart, the idle
23
+ * TTL is 30 s, and an object is only evicted after minutes idle — a partial it loses is one a
24
+ * deploy would have lost too. An object holding nothing costs nothing.
25
+ *
26
+ * ── Types are structural ────────────────────────────────────────────────────
27
+ * No `@cloudflare/workers-types`, and the classic `fetch` interface — the same reasons as
28
+ * `loginThrottleDurable.ts`.
29
+ */
30
+ import { ChunkStore, type ChunkStoreOpts } from 'cursedbelt-core/ctgr/chunk-store';
31
+ import { parseChunk, type ParsedChunk } from 'cursedbelt-core/ctgr';
32
+ import type { CtgrDecoded } from 'cursedbelt-core/ctgr/types';
33
+ import type { CtgrChunkSink } from './ctgrTunnel.js';
34
+
35
+ /** The members of a `DurableObjectNamespace` binding the store calls. */
36
+ export interface CtgrChunkNamespaceLike {
37
+ idFromName(name: string): unknown;
38
+ get(id: unknown): { fetch(request: Request): Promise<Response> };
39
+ }
40
+
41
+ /** What the object answers `POST /ctgr/chunk` with. */
42
+ type ChunkReply = { pending: true } | { chunks: Record<string, string>[] } | { error: string };
43
+
44
+ const DEFAULT_MAX_PAYLOAD_BYTES = 10 * 1024 * 1024;
45
+ const DEFAULT_TTL_MS = 30_000;
46
+
47
+ const json = (value: ChunkReply, status = 200): Response =>
48
+ new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json' } });
49
+
50
+ interface Partial {
51
+ cid: string;
52
+ total: number;
53
+ payloadLength: number;
54
+ gzip: boolean;
55
+ method: string;
56
+ parts: (Record<string, string> | undefined)[];
57
+ received: number;
58
+ bytes: number;
59
+ updatedAt: number;
60
+ }
61
+
62
+ /**
63
+ * The Durable Object. Re-export it from the Worker's entry under the `class_name` your
64
+ * `wrangler.jsonc` binds, and add it in a migration (`new_sqlite_classes`):
65
+ *
66
+ * export { CtgrChunkObject as DeskCtgrChunks } from "cursedbelt-server/ctgr-tunnel/durable";
67
+ */
68
+ export class CtgrChunkObject {
69
+ private partial: Partial | null = null;
70
+ private readonly maxPayloadBytes: number;
71
+ private readonly ttlMs: number;
72
+ private readonly now: () => number;
73
+
74
+ /** `options` is for tests; the runtime passes `(state, env)` only. */
75
+ constructor(_state?: unknown, _env?: unknown, options: ChunkStoreOpts = {}) {
76
+ this.maxPayloadBytes = options.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES;
77
+ this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
78
+ this.now = options.now ?? Date.now;
79
+ }
80
+
81
+ async fetch(request: Request): Promise<Response> {
82
+ const path = new URL(request.url).pathname;
83
+ if (request.method !== 'POST' || path !== '/ctgr/chunk') {
84
+ return json({ error: `${request.method} ${path}: only POST /ctgr/chunk` }, 404);
85
+ }
86
+ let params: Record<string, string>;
87
+ let chunk: ParsedChunk;
88
+ try {
89
+ params = (await request.json()) as Record<string, string>;
90
+ chunk = parseChunk(params);
91
+ } catch (error) {
92
+ return json({ error: error instanceof Error ? error.message : String(error) }, 400);
93
+ }
94
+ return json(this.add(params, chunk));
95
+ }
96
+
97
+ private add(params: Record<string, string>, chunk: ParsedChunk): ChunkReply {
98
+ if (this.partial && this.partial.updatedAt < this.now() - this.ttlMs) this.partial = null;
99
+ if (chunk.payloadLength > this.maxPayloadBytes) return { error: 'ctgr: payload exceeds size cap' };
100
+
101
+ let entry = this.partial;
102
+ if (!entry) {
103
+ entry = {
104
+ cid: chunk.cid,
105
+ total: chunk.total,
106
+ payloadLength: chunk.payloadLength,
107
+ gzip: chunk.gzip,
108
+ method: chunk.method,
109
+ parts: new Array(chunk.total),
110
+ received: 0,
111
+ bytes: 0,
112
+ updatedAt: this.now(),
113
+ };
114
+ this.partial = entry;
115
+ } else if (
116
+ entry.cid !== chunk.cid ||
117
+ entry.total !== chunk.total ||
118
+ entry.payloadLength !== chunk.payloadLength ||
119
+ entry.gzip !== chunk.gzip ||
120
+ entry.method !== chunk.method
121
+ ) {
122
+ // A payload's framing must not change mid-stream — the same rule `ChunkStore` keeps.
123
+ this.partial = null;
124
+ return { error: 'ctgr: inconsistent chunk metadata' };
125
+ }
126
+
127
+ entry.updatedAt = this.now();
128
+ if (entry.parts[chunk.index] === undefined) {
129
+ entry.parts[chunk.index] = params;
130
+ entry.received += 1;
131
+ entry.bytes += chunk.bytes.length;
132
+ if (entry.bytes > this.maxPayloadBytes) {
133
+ this.partial = null;
134
+ return { error: 'ctgr: payload exceeds size cap' };
135
+ }
136
+ }
137
+ if (entry.received < entry.total) return { pending: true };
138
+
139
+ this.partial = null;
140
+ return { chunks: entry.parts as Record<string, string>[] };
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The Worker half: a {@link CtgrChunkSink} for {@link mountCtgrTunnel} whose multi-chunk
146
+ * partials live in the object. Cheap to build — build it per request over `env.<BINDING>`.
147
+ *
148
+ * An object that cannot be reached THROWS, and the tunnel answers 503: a payload half-held in
149
+ * one isolate's memory is exactly the silent loss this exists to end, so there is no fallback.
150
+ */
151
+ export function createDurableChunkStore(
152
+ namespace: CtgrChunkNamespaceLike,
153
+ options: ChunkStoreOpts = {},
154
+ ): CtgrChunkSink {
155
+ return {
156
+ async add(input) {
157
+ const params =
158
+ input instanceof URLSearchParams ? Object.fromEntries(input) : { ...input };
159
+ let chunk: ParsedChunk;
160
+ try {
161
+ chunk = parseChunk(params);
162
+ } catch (error) {
163
+ return error instanceof Error ? error : new Error(String(error));
164
+ }
165
+ // One chunk needs no rendezvous — decode it here, in this isolate.
166
+ if (chunk.total === 1) return new ChunkStore(options).add(params);
167
+
168
+ const stub = namespace.get(namespace.idFromName(`ctgr:${chunk.cid}`));
169
+ const response = await stub.fetch(
170
+ new Request('https://ctgr-chunks.invalid/ctgr/chunk', {
171
+ method: 'POST',
172
+ headers: { 'content-type': 'application/json' },
173
+ body: JSON.stringify(params),
174
+ }),
175
+ );
176
+ const reply = (await response.json().catch(() => null)) as ChunkReply | null;
177
+ if (!reply) throw new Error(`ctgr chunk object answered ${response.status} with no JSON`);
178
+ if ('error' in reply) return new Error(reply.error);
179
+ if ('pending' in reply) return false;
180
+
181
+ // Every chunk is here: the one verify/decode path, in a store that lives for this call.
182
+ const assemble = new ChunkStore(options);
183
+ let result: CtgrDecoded | false | Error = new Error('ctgr: empty chunk set');
184
+ for (const part of reply.chunks) result = await assemble.add(part);
185
+ return result;
186
+ },
187
+ };
188
+ }
@@ -28,23 +28,36 @@ import { CTGR_ROUTE_PATTERN } from './ctgr.js';
28
28
  const isCtgrParam = (key: string): boolean =>
29
29
  key === '_d' || key === '_sha' || key.startsWith('_c');
30
30
 
31
+ /**
32
+ * What the tunnel needs of an accumulator: a {@link ChunkStore}, or the Worker's
33
+ * `createDurableChunkStore` (`cursedbelt-server/ctgr-tunnel/durable`), whose partials every
34
+ * isolate shares. `false` is an intermediate chunk (→ 202), an `Error` a bad payload (→ 400);
35
+ * a THROW means the store itself could not answer (→ 503).
36
+ */
37
+ export interface CtgrChunkSink {
38
+ add(params: Record<string, string> | URLSearchParams): Promise<CtgrDecoded | false | Error>;
39
+ }
40
+
31
41
  export interface CtgrTunnelOptions {
32
42
  /**
33
43
  * The accumulator for multi-chunk payloads. Share ONE across an app (the default is
34
44
  * a fresh singleton); pass your own to tune the DoS caps ({@link ChunkStore}).
45
+ * 🔴 On a Cloudflare Worker a module-scope `ChunkStore` is one ISOLATE's memory — pass
46
+ * `createDurableChunkStore(env.<BINDING>)` there, or a multi-chunk payload whose chunks
47
+ * land on different isolates is silently never delivered.
35
48
  */
36
- store?: ChunkStore;
49
+ store?: CtgrChunkSink;
37
50
  }
38
51
 
39
52
  /**
40
53
  * Mount the GET-only tunnel on `app`. Call once, on the Hono app whose real routes sit
41
54
  * at the paths the twins mirror (a twin path is `/ctgr<xx>` + the real path). Returns
42
- * the {@link ChunkStore} in use, for tests and metrics.
55
+ * the store in use, for tests and metrics.
43
56
  */
44
57
  export function mountCtgrTunnel<E extends Env = Env>(
45
58
  app: Hono<E>,
46
59
  options: CtgrTunnelOptions = {},
47
- ): ChunkStore {
60
+ ): CtgrChunkSink {
48
61
  const store = options.store ?? new ChunkStore();
49
62
  const legacy = new V0ChunkStore();
50
63
 
@@ -57,8 +70,13 @@ export function mountCtgrTunnel<E extends Env = Env>(
57
70
  return c.json({ error: 'not found', code: 'NOT_FOUND' }, 404);
58
71
  }
59
72
 
60
- const result =
61
- params._cv === undefined ? legacy.add(params, pathMethod) : await store.add(params);
73
+ let result: CtgrDecoded | false | Error;
74
+ try {
75
+ result = params._cv === undefined ? legacy.add(params, pathMethod) : await store.add(params);
76
+ } catch (error) {
77
+ console.error(`[ctgr] the chunk store could not answer: ${error instanceof Error ? error.message : String(error)}`);
78
+ return c.json({ error: 'ctgr: the chunk store could not answer — send it again', code: 'CTGR_STORE_UNAVAILABLE' }, 503);
79
+ }
62
80
 
63
81
  if (result === false) return c.json({ acknowledged: true }, 202);
64
82
  if (result instanceof Error) {
@@ -1,7 +1,15 @@
1
1
  import { Database } from 'bun:sqlite';
2
2
  import { describe, expect, test } from 'bun:test';
3
- import { assertBatchSize, assertWithinLimits, chunkForBind, inArray, LIMITS } from './limits.js';
3
+ import {
4
+ assertBatchSize,
5
+ assertWithinLimits,
6
+ chunkForBind,
7
+ inArray,
8
+ LIMITS,
9
+ maxCompoundSelectTerms,
10
+ } from './limits.js';
4
11
  import { createLocalD1 } from './local.js';
12
+ import { createRemoteD1 } from './remote.js';
5
13
  import { D1LimitError } from './types.js';
6
14
 
7
15
  describe('D1 limits are enforced on the LOCAL driver too', () => {
@@ -52,6 +60,57 @@ describe('D1 limits are enforced on the LOCAL driver too', () => {
52
60
  expect(() => assertWithinLimits(sql, [])).toThrow(/SQL statement length/);
53
61
  });
54
62
 
63
+ describe('five terms per compound SELECT — measured against production D1, 2026-09-26', () => {
64
+ const union = (terms: number): string =>
65
+ Array.from({ length: terms }, (_, k) => `SELECT ${k + 1}`).join(' UNION ');
66
+
67
+ test('six terms are refused at prepare(), before anything is bound', () => {
68
+ const db = createLocalD1(new Database(':memory:'));
69
+ expect(() => db.prepare(union(6))).toThrow(D1LimitError);
70
+ expect(() => db.prepare(union(6))).toThrow(/compound SELECT/);
71
+ });
72
+
73
+ test('five terms run — the cap is not off by one', async () => {
74
+ const db = createLocalD1(new Database(':memory:'));
75
+ const out = await db.prepare(union(5)).all();
76
+ expect(out.results).toHaveLength(5);
77
+ });
78
+
79
+ test('a recursive CTE counts its seed AND recursive terms — the shape that broke `family`', () => {
80
+ const db = createLocalD1(new Database(':memory:'));
81
+ const six = `WITH RECURSIVE r(x) AS (SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4
82
+ UNION SELECT x + 1 FROM r WHERE x < 3 UNION SELECT x + 2 FROM r WHERE x < 3) SELECT x FROM r`;
83
+ expect(() => db.prepare(six)).toThrow(D1LimitError);
84
+ });
85
+
86
+ test('a subquery is ONE term of the outer compound — the documented remedy passes', async () => {
87
+ const db = createLocalD1(new Database(':memory:'));
88
+ const folded = `WITH RECURSIVE r(x) AS (SELECT x FROM (SELECT 1 AS x UNION SELECT 2 UNION SELECT 3 UNION SELECT 4)
89
+ UNION SELECT x + 10 FROM r WHERE x < 3 UNION SELECT x + 20 FROM r WHERE x < 3) SELECT x FROM r`;
90
+ expect(maxCompoundSelectTerms(folded)).toBe(4);
91
+ const out = await db.prepare(folded).all();
92
+ expect(out.results.length).toBeGreaterThan(4);
93
+ });
94
+
95
+ test('UNION inside a string, a quoted name or a comment is not a term', () => {
96
+ const sql = `SELECT 'a UNION b UNION c UNION d UNION e UNION f' AS "UNION UNION UNION UNION UNION"
97
+ -- UNION UNION UNION UNION UNION
98
+ /* UNION UNION UNION UNION UNION */ FROM [UNION UNION UNION UNION UNION]`;
99
+ expect(maxCompoundSelectTerms(sql)).toBe(1);
100
+ });
101
+
102
+ test('separate statements and sibling subqueries are counted apart', () => {
103
+ expect(maxCompoundSelectTerms(`${union(3)}; ${union(3)}`)).toBe(3);
104
+ expect(maxCompoundSelectTerms(`SELECT * FROM (${union(3)}) JOIN (${union(3)})`)).toBe(3);
105
+ expect(maxCompoundSelectTerms(`SELECT 1 UNION ALL SELECT 2 INTERSECT SELECT 3 EXCEPT SELECT 4`)).toBe(4);
106
+ });
107
+
108
+ test('the remote driver refuses too, so a Worker fails with the remedy rather than D1\'s bare error', () => {
109
+ const binding = { prepare: () => ({}) } as unknown as Parameters<typeof createRemoteD1>[0];
110
+ expect(() => createRemoteD1(binding).prepare(union(6))).toThrow(D1LimitError);
111
+ });
112
+ });
113
+
55
114
  test('a batch over 1,000 statements is refused', () => {
56
115
  expect(() => assertBatchSize(1_001)).toThrow(D1LimitError);
57
116
  expect(() => assertBatchSize(1_000)).not.toThrow();
@@ -10,6 +10,13 @@
10
10
  * · **100 bound parameters per query.** Any seed, import or bulk insert hits this. Use
11
11
  * {@link chunkForBind}, which does the arithmetic rather than leaving it to a guess.
12
12
  * · **2 MB per string / BLOB / row.** Nothing may store a media byte in D1.
13
+ * · **5 terms per compound SELECT** — `UNION`, `UNION ALL`, `INTERSECT`, `EXCEPT`, and
14
+ * every term of a recursive CTE counts. SQLite's own default is 500, so a sixth term is
15
+ * green here and `too many terms in compound SELECT` on D1. Measured against production
16
+ * D1 on 2026-09-26: five terms answer, six are refused, inside a CTE exactly as outside
17
+ * one. `family`'s "Both families" viewport was a six-term recursive CTE, and every read
18
+ * it scoped failed in production from the D1 cutover until then. Wrap extra seed terms in
19
+ * a subquery — `SELECT id FROM (… UNION …)` is ONE term of the outer compound.
13
20
  *
14
21
  * The limits are asserted by the LOCAL driver as well as the remote one, which is the
15
22
  * whole point: a bulk insert that would fail in production fails on the laptop, at the
@@ -33,6 +40,8 @@ export const LIMITS = {
33
40
  columnsPerTable: 100,
34
41
  /** Wall-clock milliseconds one query may take. */
35
42
  queryDurationMs: 30_000,
43
+ /** Terms in one compound SELECT, recursive CTEs included. SQLite's default is 500. */
44
+ compoundSelectTerms: 5,
36
45
  } as const;
37
46
 
38
47
  const byteLength = (v: D1LikeBindable): number => {
@@ -78,6 +87,86 @@ export function assertWithinLimits(sql: string, params: readonly D1LikeBindable[
78
87
  }
79
88
  }
80
89
 
90
+ /**
91
+ * The most terms any ONE compound SELECT in `sql` has — `1` for a plain SELECT.
92
+ *
93
+ * Lexical, because `bun:sqlite` exposes no `sqlite3_limit`: string literals, quoted
94
+ * identifiers and comments are skipped, and every parenthesis opens its own count, since a
95
+ * parenthesised subquery or CTE body is a compound of its own. A `;` ends a statement.
96
+ * Multi-row `VALUES` is not counted — SQLite stopped counting it against this limit in 3.8.8.
97
+ */
98
+ export function maxCompoundSelectTerms(sql: string): number {
99
+ const stack: number[] = [0];
100
+ let max = 0;
101
+ let i = 0;
102
+ const n = sql.length;
103
+ const skipQuoted = (close: string): void => {
104
+ i++;
105
+ while (i < n) {
106
+ if (sql[i] === close) {
107
+ // A doubled quote is an escaped one, inside the same literal.
108
+ if (sql[i + 1] === close && close !== ']') {
109
+ i += 2;
110
+ continue;
111
+ }
112
+ break;
113
+ }
114
+ i++;
115
+ }
116
+ i++;
117
+ };
118
+ while (i < n) {
119
+ const c = sql[i] as string;
120
+ if (c === "'" || c === '"' || c === '`') skipQuoted(c);
121
+ else if (c === '[') skipQuoted(']');
122
+ else if (c === '-' && sql[i + 1] === '-') {
123
+ while (i < n && sql[i] !== '\n') i++;
124
+ } else if (c === '/' && sql[i + 1] === '*') {
125
+ const end = sql.indexOf('*/', i + 2);
126
+ i = end === -1 ? n : end + 2;
127
+ } else if (c === '(') {
128
+ stack.push(0);
129
+ i++;
130
+ } else if (c === ')') {
131
+ if (stack.length > 1) stack.pop();
132
+ i++;
133
+ } else if (c === ';') {
134
+ stack.length = 1;
135
+ stack[0] = 0;
136
+ i++;
137
+ } else if (/[A-Za-z_]/.test(c)) {
138
+ let j = i + 1;
139
+ while (j < n && /[A-Za-z0-9_$]/.test(sql[j] as string)) j++;
140
+ const word = sql.slice(i, j).toUpperCase();
141
+ if (word === 'UNION' || word === 'INTERSECT' || word === 'EXCEPT') {
142
+ const top = stack.length - 1;
143
+ stack[top] = (stack[top] as number) + 1;
144
+ max = Math.max(max, stack[top] as number);
145
+ }
146
+ i = j;
147
+ } else i++;
148
+ }
149
+ return max + 1;
150
+ }
151
+
152
+ /**
153
+ * Refuse a statement whose compound SELECT has more terms than D1 allows — see
154
+ * `LIMITS.compoundSelectTerms`. Called at `prepare()`, because a statement with nothing to
155
+ * bind never reaches {@link assertWithinLimits}.
156
+ */
157
+ export function assertCompoundSelectTerms(sql: string): void {
158
+ const terms = maxCompoundSelectTerms(sql);
159
+ if (terms > LIMITS.compoundSelectTerms) {
160
+ throw new D1LimitError(
161
+ 'terms in one compound SELECT',
162
+ terms,
163
+ LIMITS.compoundSelectTerms,
164
+ 'D1 answers `too many terms in compound SELECT`. Fold extra terms into a subquery — ' +
165
+ '`SELECT id FROM (a UNION b UNION c)` is one term of the outer compound.',
166
+ );
167
+ }
168
+ }
169
+
81
170
  /** Refuse a `batch()` bigger than one Worker invocation may issue. */
82
171
  export function assertBatchSize(count: number): void {
83
172
  if (count > LIMITS.queriesPerInvocation) {
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { Database } from 'bun:sqlite';
18
- import { assertBatchSize, assertWithinLimits } from './limits.js';
18
+ import { assertBatchSize, assertCompoundSelectTerms, assertWithinLimits } from './limits.js';
19
19
  import {
20
20
  type D1LikeBindable,
21
21
  type D1LikeDatabase,
@@ -171,6 +171,7 @@ export function createLocalD1(db: Database): D1LikeDatabase {
171
171
  flavor: 'local',
172
172
 
173
173
  prepare(sql: string): D1LikeStatement {
174
+ assertCompoundSelectTerms(sql);
174
175
  return new LocalStatement(db, sql);
175
176
  },
176
177
 
@@ -15,7 +15,7 @@
15
15
  * fake that has no `workerd` in sight.
16
16
  */
17
17
 
18
- import { assertBatchSize, assertWithinLimits } from './limits.js';
18
+ import { assertBatchSize, assertCompoundSelectTerms, assertWithinLimits } from './limits.js';
19
19
  import {
20
20
  type D1LikeBindable,
21
21
  type D1LikeDatabase,
@@ -141,6 +141,7 @@ export function createRemoteD1(binding: D1BindingLike): D1LikeDatabase {
141
141
  flavor: 'd1',
142
142
 
143
143
  prepare(sql: string): D1LikeStatement {
144
+ assertCompoundSelectTerms(sql);
144
145
  return new RemoteStatement(binding.prepare(sql), sql);
145
146
  },
146
147