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.
- package/dist/server/activity/migrations.js +10 -0
- package/dist/server/activity/schema.js +8 -0
- package/dist/server/d1/limits.d.ts +24 -0
- package/dist/server/d1/limits.js +94 -0
- package/dist/server/d1/local.js +2 -1
- package/dist/server/d1/remote.js +2 -1
- package/package.json +1 -1
- package/src/barrelsReachNoOptionalPeer.spec.ts +16 -12
- package/src/leafSubpathsImportNothing.spec.ts +22 -16
- package/src/lockfileIsTracked.spec.ts +9 -5
- package/src/server/activity/actorIndex.spec.ts +25 -0
- package/src/server/activity/d1.spec.ts +1 -1
- package/src/server/activity/migrations.ts +8 -0
- package/src/server/activity/schema.ts +8 -0
- package/src/server/d1/limits.spec.ts +60 -1
- package/src/server/d1/limits.ts +89 -0
- package/src/server/d1/local.ts +2 -1
- package/src/server/d1/remote.ts +2 -1
- package/src/server/serveBunOverload.spec.ts +12 -7
- package/src/shippedFilesAreTracked.spec.ts +8 -8
|
@@ -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
|
/**
|
package/dist/server/d1/limits.js
CHANGED
|
@@ -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) {
|
package/dist/server/d1/local.js
CHANGED
|
@@ -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
|
/**
|
package/dist/server/d1/remote.js
CHANGED
|
@@ -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.
|
|
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
|
|
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 (
|
|
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.
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
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 =
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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();
|
package/src/server/d1/limits.ts
CHANGED
|
@@ -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) {
|
package/src/server/d1/local.ts
CHANGED
|
@@ -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
|
|
package/src/server/d1/remote.ts
CHANGED
|
@@ -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
|
|
25
|
-
|
|
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
|
|
41
|
-
|
|
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(
|