cursedbelt-server 4.38.0 → 4.40.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.
@@ -27,4 +27,14 @@ export const ACTIVITY_MIGRATIONS = [
27
27
  db.run(statement);
28
28
  },
29
29
  },
30
+ {
31
+ // A store that ran 0001 before the case-insensitive actor index existed never gets it from
32
+ // 0001 again. Idempotent, so on a fresh store (where 0001 already built it) it is a no-op.
33
+ name: "0002_activity_actor_ci",
34
+ up(db) {
35
+ for (const statement of ACTIVITY_DDL)
36
+ if (statement.includes("idx_activity_events_actor_ci"))
37
+ db.run(statement);
38
+ },
39
+ },
30
40
  ];
@@ -33,6 +33,14 @@ export const ACTIVITY_DDL = [
33
33
  // question, and the one a fleet-wide search runs first.
34
34
  `CREATE INDEX IF NOT EXISTS idx_activity_events_actor
35
35
  ON ${ACTIVITY_EVENTS_TABLE} (actor, event_id DESC)`,
36
+ // 🔴 The index the actor filter can actually USE. `buildStream` matches an actor
37
+ // case-insensitively — `LOWER(actor) = LOWER(?)` — and SQLite cannot use the plain
38
+ // `actor` index above for that, so every actor query scanned the stream: measured
39
+ // 2026-09-28 (task 2254) on auth-stage, auth's sign-in familiarity read (actor + action +
40
+ // outcome, on every sign-in and app-open) cost 760 rows a call and 2.2M rows in four
41
+ // days, growing with the log for ever. With this index: 41 rows, flat.
42
+ `CREATE INDEX IF NOT EXISTS idx_activity_events_actor_ci
43
+ ON ${ACTIVITY_EVENTS_TABLE} (LOWER(actor), action, at DESC)`,
36
44
  // "Everything that ever happened to this record."
37
45
  `CREATE INDEX IF NOT EXISTS idx_activity_events_resource
38
46
  ON ${ACTIVITY_EVENTS_TABLE} (kind, resource_id, event_id DESC)`,
@@ -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.38.0",
3
+ "version": "4.40.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.",
@@ -132,19 +132,23 @@ const packageOf = (specifier: string): string | undefined => {
132
132
  * non-zero exit or an empty bundle THROWS rather than returning an empty set. That is the
133
133
  * one way a check like this dies quietly.
134
134
  */
135
- const scan = (entry: string, subpath: string): { packages: Set<string>; bun: Set<string> } => {
135
+ const scan = async (entry: string, subpath: string): Promise<{ packages: Set<string>; bun: Set<string> }> => {
136
136
  const outdir = `${process.env.TMPDIR ?? '/tmp'}/cursedbelt-server-barrel-scan/${subpath.replace(/[^a-z0-9]+/gi, '-')}`;
137
- const build = Bun.spawnSync(['bun', 'build', entry, '--target=bun', '--packages=external', '--outdir', outdir], {
137
+ const child = Bun.spawn(['bun', 'build', entry, '--target=bun', '--packages=external', '--outdir', outdir], {
138
138
  cwd: REPO,
139
+ stdin: 'ignore',
139
140
  stdout: 'pipe',
140
141
  stderr: 'pipe',
141
142
  });
143
+ const [, stderr, exitCode] = await Promise.all([
144
+ new Response(child.stdout).text(),
145
+ new Response(child.stderr).text(),
146
+ child.exited,
147
+ ]);
142
148
  const emitted = [...new Bun.Glob('**/*.js').scanSync({ cwd: outdir, onlyFiles: true })];
143
149
  const bundle = emitted.map((f) => readFileSync(`${outdir}/${f}`, 'utf8')).join('\n');
144
- if (build.exitCode !== 0 || bundle.trim() === '') {
145
- throw new Error(
146
- `could not bundle ${subpath} (${entry}, exit ${build.exitCode}) — a check that cannot measure is not a passing check:\n${build.stderr.toString()}`,
147
- );
150
+ if (exitCode !== 0 || bundle.trim() === '') {
151
+ throw new Error(`could not bundle ${subpath} (${entry}, exit ${exitCode}) — a check that cannot measure is not a passing check:\n${stderr}`);
148
152
  }
149
153
  const packages = new Set<string>();
150
154
  const bun = new Set<string>();
@@ -168,8 +172,8 @@ const scan = (entry: string, subpath: string): { packages: Set<string>; bun: Set
168
172
  * One bundle per subpath, shared by both assertions. Bundling is the expensive part of this
169
173
  * file and the answer cannot change between two `it`s in the same run.
170
174
  */
171
- const scans = new Map<string, { packages: Set<string>; bun: Set<string> }>();
172
- const scanOnce = (entry: string, subpath: string): { packages: Set<string>; bun: Set<string> } => {
175
+ const scans = new Map<string, Promise<{ packages: Set<string>; bun: Set<string> }>>();
176
+ const scanOnce = (entry: string, subpath: string): Promise<{ packages: Set<string>; bun: Set<string> }> => {
173
177
  const cached = scans.get(subpath);
174
178
  if (cached !== undefined) return cached;
175
179
  const fresh = scan(entry, subpath);
@@ -190,8 +194,8 @@ describe('no public subpath statically reaches what its consumers may not have',
190
194
 
191
195
  for (const { subpath, entry } of SUBPATHS) {
192
196
  const allowed = MAY_DRAG[subpath] ?? [];
193
- it(`${subpath} drags ${allowed.length === 0 ? 'no optional peer' : allowed.join(' + ') + ' and nothing more'}`, () => {
194
- const dragged = [...scanOnce(entry, subpath).packages].filter((n) => OPTIONAL_PEERS.has(n)).sort();
197
+ it(`${subpath} drags ${allowed.length === 0 ? 'no optional peer' : allowed.join(' + ') + ' and nothing more'}`, async () => {
198
+ const dragged = [...(await scanOnce(entry, subpath)).packages].filter((n) => OPTIONAL_PEERS.has(n)).sort();
195
199
  const unexpected = dragged.filter((n) => !allowed.includes(n));
196
200
  expect(
197
201
  unexpected,
@@ -213,8 +217,8 @@ describe('no public subpath statically reaches what its consumers may not have',
213
217
 
214
218
  for (const { subpath, entry } of SUBPATHS) {
215
219
  const allowed = MAY_NEED_BUN[subpath] ?? [];
216
- it(`${subpath} ${allowed.length === 0 ? 'loads on a Worker — no bun builtin' : `needs ${allowed.join(' + ')} and nothing more`}`, () => {
217
- const reached = [...scanOnce(entry, subpath).bun].sort();
220
+ it(`${subpath} ${allowed.length === 0 ? 'loads on a Worker — no bun builtin' : `needs ${allowed.join(' + ')} and nothing more`}`, async () => {
221
+ const reached = [...(await scanOnce(entry, subpath)).bun].sort();
218
222
  const unexpected = reached.filter((n) => !allowed.includes(n));
219
223
  expect(
220
224
  unexpected,
@@ -361,16 +361,20 @@ console.log(JSON.stringify(out));
361
361
  `,
362
362
  );
363
363
 
364
- const child = Bun.spawnSync(['bun', 'run', 'entry.ts'], {
364
+ const child = Bun.spawn(['bun', 'run', 'entry.ts'], {
365
365
  cwd: root,
366
+ stdin: 'ignore',
366
367
  stdout: 'pipe',
367
368
  stderr: 'pipe',
368
369
  });
369
- const stdout = child.stdout.toString().trim();
370
- if (child.exitCode !== 0 || stdout === '') {
371
- throw new Error(
372
- `the fixture could not run at all (exit ${child.exitCode}) — that is a broken check, not a passing one:\n${child.stderr.toString()}`,
373
- );
370
+ const [stdoutRaw, stderr, exitCode] = await Promise.all([
371
+ new Response(child.stdout).text(),
372
+ new Response(child.stderr).text(),
373
+ child.exited,
374
+ ]);
375
+ const stdout = stdoutRaw.trim();
376
+ if (exitCode !== 0 || stdout === '') {
377
+ throw new Error(`the fixture could not run at all (exit ${exitCode}) — that is a broken check, not a passing one:\n${stderr}`);
374
378
  }
375
379
  return JSON.parse(stdout) as Record<string, Outcome>;
376
380
  };
@@ -412,16 +416,18 @@ const OPTIONAL_PEERS = new Set(
412
416
  /** `import x from "pkg"`, `import "pkg"`, `export … from "pkg"` — one line each, as bun emits. */
413
417
  const STATIC_SPECIFIER = /^(?:import|export)\s[^\n]*?["']([^"'\n]+)["'];?$|^import\s*["']([^"'\n]+)["'];?$/gm;
414
418
 
415
- const staticOptionalPeersOf = (entry: string): string[] => {
416
- const build = Bun.spawnSync(
419
+ const staticOptionalPeersOf = async (entry: string): Promise<string[]> => {
420
+ const child = Bun.spawn(
417
421
  ['bun', 'build', entry, '--target=bun', '--packages=external'],
418
- { cwd: REPO, stdout: 'pipe', stderr: 'pipe' },
422
+ { cwd: REPO, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
419
423
  );
420
- const bundle = build.stdout.toString();
421
- if (build.exitCode !== 0 || bundle.trim() === '') {
422
- throw new Error(
423
- `could not bundle ${entry} (exit ${build.exitCode}) — a check that cannot measure is not a passing check:\n${build.stderr.toString()}`,
424
- );
424
+ const [bundle, stderr, exitCode] = await Promise.all([
425
+ new Response(child.stdout).text(),
426
+ new Response(child.stderr).text(),
427
+ child.exited,
428
+ ]);
429
+ if (exitCode !== 0 || bundle.trim() === '') {
430
+ throw new Error(`could not bundle ${entry} (exit ${exitCode}) — a check that cannot measure is not a passing check:\n${stderr}`);
425
431
  }
426
432
  const found = new Set<string>();
427
433
  for (const match of bundle.matchAll(STATIC_SPECIFIER)) {
@@ -478,9 +484,9 @@ describe('barrels reach no optional peer statically', () => {
478
484
  });
479
485
 
480
486
  for (const target of NO_STATIC_OPTIONAL_PEERS) {
481
- it(`${pkg.name}/${target.subpath.slice(2)} costs a consumer no optional peer to import`, () => {
487
+ it(`${pkg.name}/${target.subpath.slice(2)} costs a consumer no optional peer to import`, async () => {
482
488
  expect(
483
- staticOptionalPeersOf(target.entry),
489
+ await staticOptionalPeersOf(target.entry),
484
490
  `importing ${pkg.name}/${target.subpath.slice(2)} statically pulls these OPTIONAL peers, so every consumer must install them for code it may never call. Either import the leaf module that has what you need instead of a sibling INDEX (that is how 'otplib' got in here twice), or make the dependency lazy: \`const { x } = await import('pkg')\` inside the function that needs it.`,
485
491
  ).toEqual([]);
486
492
  });
@@ -23,7 +23,6 @@
23
23
  * to skip it. Ignored-vs-not is the property that actually regresses.
24
24
  */
25
25
  import { describe, expect, test } from 'bun:test'
26
- import { spawnSync } from 'node:child_process'
27
26
  import { existsSync } from 'node:fs'
28
27
  import { fileURLToPath } from 'node:url'
29
28
 
@@ -37,13 +36,18 @@ describe('bun.lock is part of the checkout', () => {
37
36
  ).toBe(true)
38
37
  })
39
38
 
40
- test('git is not told to ignore it', () => {
41
- const git = spawnSync('git', ['check-ignore', '-q', 'bun.lock'], { cwd: repoRoot })
42
- if (git.error) return // no git on this host: nothing to assert about ignore rules
39
+ test('git is not told to ignore it', async () => {
40
+ let status: number
41
+ try {
42
+ const child = Bun.spawn(['git', 'check-ignore', '-q', 'bun.lock'], { cwd: repoRoot, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
43
+ status = await child.exited
44
+ } catch {
45
+ return // no git on this host: nothing to assert about ignore rules
46
+ }
43
47
 
44
48
  // exit 0 = the path IS ignored; 1 = it is not; anything else = git could not tell us.
45
49
  expect(
46
- git.status,
50
+ status,
47
51
  'bun.lock is gitignored, so a fresh clone has no lockfile and `bun install --frozen-lockfile` cannot pass there. Delete the stanza from .gitignore rather than the assertion.',
48
52
  ).not.toBe(0)
49
53
  })
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 🔴 The actor filter must reach an index (task 2254).
3
+ *
4
+ * `buildStream` matches an actor with `LOWER(actor) = LOWER(?)`, which the plain `actor` index
5
+ * cannot serve — so until `idx_activity_events_actor_ci` every actor query scanned the stream.
6
+ * Measured on auth-stage, 2026-09-28: 760 rows a call on auth's sign-in familiarity read, growing
7
+ * with the log; 41 with the index. This pins the PLAN, so dropping the index or changing the
8
+ * filter's shape fails here rather than on the next bill.
9
+ */
10
+ import { Database } from "bun:sqlite";
11
+ import { expect, test } from "bun:test";
12
+ import { buildStream } from "./query.js";
13
+ import { ACTIVITY_DDL } from "./schema.js";
14
+
15
+ test("a case-insensitive actor + action filter searches idx_activity_events_actor_ci, never a scan", () => {
16
+ const db = new Database(":memory:");
17
+ for (const ddl of ACTIVITY_DDL) db.run(ddl);
18
+ const insert = db.prepare("INSERT INTO activity_events (at, app, actor, action, outcome) VALUES (?, 'auth', ?, ?, 'ok')");
19
+ for (let i = 0; i < 500; i++) insert.run(new Date(1_790_000_000_000 + i).toISOString(), `user${i % 50}@example.com`, i % 2 ? "auth.sign-in" : "auth.app-open");
20
+ db.run("ANALYZE");
21
+ const { sql, params } = buildStream({ actor: "User7@Example.com", action: "auth.sign-in", outcome: "ok", limit: 20 }, []);
22
+ const plan = (db.query(`EXPLAIN QUERY PLAN ${sql}`).all(...(params as never[])) as { detail: string }[]).map((r) => r.detail).join(" | ");
23
+ expect(plan).toContain("idx_activity_events_actor_ci");
24
+ expect(plan).not.toMatch(/SCAN activity_events/);
25
+ });
@@ -129,7 +129,7 @@ describe("cursedbelt-server/activity/d1", () => {
129
129
  const migrated = new Database(":memory:");
130
130
  runMigrations(migrated, ACTIVITY_MIGRATIONS);
131
131
  expect(shape(withDdl(ACTIVITY_DDL))).toEqual(shape(migrated));
132
- expect(shape(migrated).length).toBe(5);
132
+ expect(shape(migrated).length).toBe(6);
133
133
  });
134
134
 
135
135
  test("🔴 refuses an event that cannot be filtered or attributed, and writes nothing", async () => {
@@ -30,4 +30,12 @@ export const ACTIVITY_MIGRATIONS: readonly Migration[] = [
30
30
  for (const statement of ACTIVITY_DDL) db.run(statement);
31
31
  },
32
32
  },
33
+ {
34
+ // A store that ran 0001 before the case-insensitive actor index existed never gets it from
35
+ // 0001 again. Idempotent, so on a fresh store (where 0001 already built it) it is a no-op.
36
+ name: "0002_activity_actor_ci",
37
+ up(db: Database): void {
38
+ for (const statement of ACTIVITY_DDL) if (statement.includes("idx_activity_events_actor_ci")) db.run(statement);
39
+ },
40
+ },
33
41
  ];
@@ -35,6 +35,14 @@ export const ACTIVITY_DDL: readonly string[] = [
35
35
  // question, and the one a fleet-wide search runs first.
36
36
  `CREATE INDEX IF NOT EXISTS idx_activity_events_actor
37
37
  ON ${ACTIVITY_EVENTS_TABLE} (actor, event_id DESC)`,
38
+ // 🔴 The index the actor filter can actually USE. `buildStream` matches an actor
39
+ // case-insensitively — `LOWER(actor) = LOWER(?)` — and SQLite cannot use the plain
40
+ // `actor` index above for that, so every actor query scanned the stream: measured
41
+ // 2026-09-28 (task 2254) on auth-stage, auth's sign-in familiarity read (actor + action +
42
+ // outcome, on every sign-in and app-open) cost 760 rows a call and 2.2M rows in four
43
+ // days, growing with the log for ever. With this index: 41 rows, flat.
44
+ `CREATE INDEX IF NOT EXISTS idx_activity_events_actor_ci
45
+ ON ${ACTIVITY_EVENTS_TABLE} (LOWER(actor), action, at DESC)`,
38
46
  // "Everything that ever happened to this record."
39
47
  `CREATE INDEX IF NOT EXISTS idx_activity_events_resource
40
48
  ON ${ACTIVITY_EVENTS_TABLE} (kind, resource_id, event_id DESC)`,
@@ -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
 
@@ -20,26 +20,31 @@ import { fileURLToPath } from 'node:url';
20
20
  const DIR = fileURLToPath(new URL('./typetests/', import.meta.url));
21
21
  const TSC = fileURLToPath(new URL('../../node_modules/typescript/bin/tsc', import.meta.url));
22
22
 
23
- const tsc = (project: string) => {
24
- const run = Bun.spawnSync(['bun', TSC, '-p', project], { stdout: 'pipe', stderr: 'pipe' });
25
- return { code: run.exitCode, out: `${run.stdout.toString()}${run.stderr.toString()}` };
23
+ const tsc = async (project: string) => {
24
+ const child = Bun.spawn(['bun', TSC, '-p', project], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
25
+ const [stdout, stderr, code] = await Promise.all([
26
+ new Response(child.stdout).text(),
27
+ new Response(child.stderr).text(),
28
+ child.exited,
29
+ ]);
30
+ return { code, out: `${stdout}${stderr}` };
26
31
  };
27
32
 
28
33
  describe("Bun.serve's websocket overload", () => {
29
- it('the trap is still live in bun’s types, and serveBun is the seam that never meets it', () => {
30
- const run = tsc(`${DIR}tsconfig.json`);
34
+ it('the trap is still live in bun’s types, and serveBun is the seam that never meets it', async () => {
35
+ const run = await tsc(`${DIR}tsconfig.json`);
31
36
  expect(run.out).toBe('');
32
37
  expect(run.code).toBe(0);
33
38
  }, 60_000);
34
39
 
35
- it('🔴 without the directives the SAME lines fail — so the check above can go red', () => {
40
+ it('🔴 without the directives the SAME lines fail — so the check above can go red', async () => {
36
41
  const source = readFileSync(`${DIR}serveBunOverload.ts`, 'utf8').replace(/^\s*\/\/ @ts-expect-error.*$/gm, '');
37
42
  const file = `${DIR}serveBunOverload.stripped.ts`;
38
43
  const project = `${DIR}tsconfig.stripped.json`;
39
44
  writeFileSync(file, source);
40
45
  writeFileSync(project, JSON.stringify({ extends: './tsconfig.json', include: ['serveBunOverload.stripped.ts'] }));
41
46
  try {
42
- const run = tsc(project);
47
+ const run = await tsc(project);
43
48
  expect(run.code).not.toBe(0);
44
49
  const errors = run.out.split('\n').filter((line) => line.includes('error TS'));
45
50
  expect(errors).toHaveLength(2);
@@ -25,7 +25,6 @@
25
25
  * when `git` cannot answer — a check that silently degrades to green is worse than none.
26
26
  */
27
27
  import { describe, expect, test } from 'bun:test';
28
- import { spawnSync } from 'node:child_process';
29
28
  import { existsSync } from 'node:fs';
30
29
  import { fileURLToPath } from 'node:url';
31
30
  import pkg from '../package.json';
@@ -36,14 +35,15 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url));
36
35
  const shippedDirs = (): string[] =>
37
36
  ((pkg as { files?: string[] }).files ?? []).filter((f) => f !== 'dist' && existsSync(`${repoRoot}/${f}`));
38
37
 
39
- const git = (...args: string[]): { ok: boolean; out: string } => {
40
- const r = spawnSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8' });
41
- return { ok: r.status === 0, out: (r.stdout ?? '').trim() };
38
+ const git = async (...args: string[]): Promise<{ ok: boolean; out: string }> => {
39
+ const child = Bun.spawn(['git', '-C', repoRoot, ...args], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
40
+ const [out, , code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
41
+ return { ok: code === 0, out: out.trim() };
42
42
  };
43
43
 
44
44
  describe('every file inside the published surface is tracked', () => {
45
- test('git can answer at all — a degraded check must refuse, not pass', () => {
46
- expect(git('rev-parse', '--is-inside-work-tree').ok, 'not a git checkout, so this check proves nothing').toBe(true);
45
+ test('git can answer at all — a degraded check must refuse, not pass', async () => {
46
+ expect((await git('rev-parse', '--is-inside-work-tree')).ok, 'not a git checkout, so this check proves nothing').toBe(true);
47
47
  });
48
48
 
49
49
  test('package.json names a shippable directory to scan', () => {
@@ -52,10 +52,10 @@ describe('every file inside the published surface is tracked', () => {
52
52
  expect(shippedDirs().length, '`files` names no committed directory — nothing would be scanned').toBeGreaterThan(0);
53
53
  });
54
54
 
55
- test('no untracked file sits inside a directory `files` ships', () => {
55
+ test('no untracked file sits inside a directory `files` ships', async () => {
56
56
  // --others = untracked; --exclude-standard honours .gitignore, so deliberately
57
57
  // ignored paths (.agent.noindex, node_modules) are not offences.
58
- const { ok, out } = git('ls-files', '--others', '--exclude-standard', '--', ...shippedDirs());
58
+ const { ok, out } = await git('ls-files', '--others', '--exclude-standard', '--', ...shippedDirs());
59
59
  expect(ok, 'git ls-files failed').toBe(true);
60
60
  const untracked = out === '' ? [] : out.split('\n');
61
61
  expect(