cursedbelt-server 2.0.0 → 3.0.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.
Files changed (82) hide show
  1. package/dist/server/bench/assert.d.ts +61 -0
  2. package/dist/server/bench/assert.js +117 -0
  3. package/dist/server/bench/budget.d.ts +130 -0
  4. package/dist/server/bench/budget.js +131 -0
  5. package/dist/server/bench/cpuBudget.d.ts +45 -0
  6. package/dist/server/bench/cpuBudget.js +34 -0
  7. package/dist/server/bench/cpuClock.d.ts +65 -0
  8. package/dist/server/bench/cpuClock.js +100 -0
  9. package/dist/server/bench/index.d.ts +40 -0
  10. package/dist/server/bench/index.js +40 -0
  11. package/dist/server/bench/recorder.d.ts +70 -0
  12. package/dist/server/bench/recorder.js +95 -0
  13. package/dist/server/bench/runBench.d.ts +61 -0
  14. package/dist/server/bench/runBench.js +61 -0
  15. package/dist/server/d1/backup.d.ts +110 -0
  16. package/dist/server/d1/backup.js +128 -0
  17. package/dist/server/d1/fakeD1.d.ts +41 -0
  18. package/dist/server/d1/fakeD1.js +185 -0
  19. package/dist/server/d1/index.d.ts +24 -0
  20. package/dist/server/d1/index.js +24 -0
  21. package/dist/server/d1/kysely.d.ts +56 -0
  22. package/dist/server/d1/kysely.js +138 -0
  23. package/dist/server/d1/limits.d.ts +56 -0
  24. package/dist/server/d1/limits.js +96 -0
  25. package/dist/server/d1/local.d.ts +31 -0
  26. package/dist/server/d1/local.js +135 -0
  27. package/dist/server/d1/remote.d.ts +59 -0
  28. package/dist/server/d1/remote.js +124 -0
  29. package/dist/server/d1/scheduling.d.ts +113 -0
  30. package/dist/server/d1/scheduling.js +164 -0
  31. package/dist/server/d1/types.d.ts +143 -0
  32. package/dist/server/d1/types.js +80 -0
  33. package/dist/server/d1/values.d.ts +50 -0
  34. package/dist/server/d1/values.js +124 -0
  35. package/dist/server/master-lock/guard.d.ts +10 -0
  36. package/dist/server/master-lock/guard.js +70 -19
  37. package/dist/server/master-lock/index.d.ts +1 -1
  38. package/dist/server/master-lock/index.js +1 -1
  39. package/dist/server/master-lock/lockPage.d.ts +1 -1
  40. package/dist/server/master-lock/lockPage.js +68 -3
  41. package/dist/server/master-lock/masterLock.d.ts +250 -76
  42. package/dist/server/master-lock/masterLock.js +426 -114
  43. package/dist/server/master-lock/principals.js +6 -1
  44. package/dist/server/master-lock/seed.d.ts +5 -1
  45. package/dist/server/master-lock/seed.js +18 -1
  46. package/package.json +21 -3
  47. package/src/leafSubpathsImportNothing.spec.ts +15 -3
  48. package/src/server/bench/assert.ts +192 -0
  49. package/src/server/bench/budget.spec.ts +126 -0
  50. package/src/server/bench/budget.ts +207 -0
  51. package/src/server/bench/cpuBudget.spec.ts +302 -0
  52. package/src/server/bench/cpuBudget.ts +81 -0
  53. package/src/server/bench/cpuClock.ts +119 -0
  54. package/src/server/bench/index.ts +81 -0
  55. package/src/server/bench/recorder.ts +163 -0
  56. package/src/server/bench/runBench.ts +110 -0
  57. package/src/server/d1/backup.spec.ts +121 -0
  58. package/src/server/d1/backup.ts +186 -0
  59. package/src/server/d1/fakeD1.ts +193 -0
  60. package/src/server/d1/index.ts +62 -0
  61. package/src/server/d1/kysely.spec.ts +145 -0
  62. package/src/server/d1/kysely.ts +169 -0
  63. package/src/server/d1/limits.spec.ts +90 -0
  64. package/src/server/d1/limits.ts +123 -0
  65. package/src/server/d1/local.ts +173 -0
  66. package/src/server/d1/remote.ts +182 -0
  67. package/src/server/d1/sameShape.spec.ts +279 -0
  68. package/src/server/d1/scheduling.spec.ts +120 -0
  69. package/src/server/d1/scheduling.ts +210 -0
  70. package/src/server/d1/types.ts +163 -0
  71. package/src/server/d1/values.ts +138 -0
  72. package/src/server/master-lock/accounts.spec.ts +308 -0
  73. package/src/server/master-lock/guard.spec.ts +69 -7
  74. package/src/server/master-lock/guard.ts +78 -20
  75. package/src/server/master-lock/index.ts +3 -0
  76. package/src/server/master-lock/lockPage.ts +70 -3
  77. package/src/server/master-lock/masterLock.spec.ts +56 -23
  78. package/src/server/master-lock/masterLock.ts +529 -151
  79. package/src/server/master-lock/principals.spec.ts +45 -15
  80. package/src/server/master-lock/principals.ts +6 -1
  81. package/src/server/master-lock/seed.spec.ts +7 -2
  82. package/src/server/master-lock/seed.ts +22 -2
@@ -0,0 +1,193 @@
1
+ /**
2
+ * A `bun:sqlite`-backed stand-in for a real D1 binding, for tests that must run without a
3
+ * Worker.
4
+ *
5
+ * ## 🔴 It is a DIVERGENCE SIMULATOR, not a mirror of our driver
6
+ *
7
+ * The whole value of this file rests on one discipline: it reproduces the ways D1 differs
8
+ * from `bun:sqlite`, deliberately and on purpose. A fake written to agree with
9
+ * `createRemoteD1` would make `sameShape.spec.ts` assert that our code agrees with itself,
10
+ * which is a test that can never fail and therefore is not one.
11
+ *
12
+ * So it does the awkward things D1 really does, each measured against `bun:sqlite` on
13
+ * 2026-09-16 and each one a real defect this fleet would otherwise ship:
14
+ *
15
+ * 1. **BLOBs come back as `number[]`**, not `Uint8Array` — D1 crosses the wire as JSON.
16
+ * 2. **`undefined` and `boolean` binds are REFUSED**, where `bun:sqlite` silently binds
17
+ * NULL and silently coerces to 0/1.
18
+ * 3. **`bigint` binds are refused** — `bun:sqlite` coerces them to numbers, losing
19
+ * precision above 2^53 without a word.
20
+ * 4. **Results are wrapped** in `{ results, success, meta }` rather than returned bare.
21
+ * 5. **`BEGIN`/`COMMIT` is refused** — D1 has no interactive transaction.
22
+ *
23
+ * SQL semantics are real: it runs against an actual in-memory SQLite. Only the edges are
24
+ * emulated, which are exactly the edges under test.
25
+ *
26
+ * 🔴 **It is not a substitute for running against real D1 once.** It cannot reproduce
27
+ * network failure, a `rows_read` budget or the 30-second query cap. It is the thing that
28
+ * makes a port testable on a laptop, and `190`'s Worker preview is the thing that proves
29
+ * it.
30
+ */
31
+
32
+ import type { Database } from 'bun:sqlite';
33
+ import type { D1BindingLike, D1BindingResult, D1BindingStatement } from './remote';
34
+
35
+ /** The bind types a real D1 binding accepts. Everything else throws. */
36
+ function assertD1Bindable(value: unknown, index: number): void {
37
+ if (value === null) return;
38
+ const t = typeof value;
39
+ if (t === 'string' || t === 'number') {
40
+ if (t === 'number' && !Number.isFinite(value as number)) {
41
+ throw new TypeError(`D1_TYPE_ERROR: value ${String(value)} at position ${index + 1} is not a finite number`);
42
+ }
43
+ return;
44
+ }
45
+ if (value instanceof ArrayBuffer || value instanceof Uint8Array) return;
46
+ throw new TypeError(
47
+ `D1_TYPE_ERROR: Type '${t === 'undefined' ? 'undefined' : ((value as object)?.constructor?.name ?? t)}' ` +
48
+ `not supported for value at position ${index + 1}`,
49
+ );
50
+ }
51
+
52
+ /** D1 returns BLOB columns as plain arrays of byte values, because the wire is JSON. */
53
+ function toWireValue(value: unknown): unknown {
54
+ if (value instanceof Uint8Array) return Array.from(value);
55
+ if (typeof value === 'bigint') return Number(value);
56
+ return value ?? null;
57
+ }
58
+
59
+ const toWireRow = (row: Record<string, unknown>): Record<string, unknown> => {
60
+ const out: Record<string, unknown> = {};
61
+ for (const k of Object.keys(row)) out[k] = toWireValue(row[k]);
62
+ return out;
63
+ };
64
+
65
+ const REFUSED_SQL = /^\s*(begin|commit|rollback|savepoint|release|vacuum|attach|detach|pragma\s+journal_mode)\b/i;
66
+
67
+ function assertRunnableOnD1(sql: string): void {
68
+ if (REFUSED_SQL.test(sql)) {
69
+ throw new Error(
70
+ `D1_ERROR: not authorized — D1 does not permit '${sql.trim().split(/\s+/)[0]}'. ` +
71
+ 'Use batch() for atomicity; D1 has no interactive transaction and no WAL to configure.',
72
+ );
73
+ }
74
+ }
75
+
76
+ class FakeD1Statement implements D1BindingStatement {
77
+ constructor(
78
+ private readonly db: Database,
79
+ /** Public so `batch()` can re-run the statement inside its transaction. */
80
+ readonly sql: string,
81
+ readonly params: unknown[] = [],
82
+ ) {}
83
+
84
+ bind(...values: unknown[]): D1BindingStatement {
85
+ values.forEach((v, i) => assertD1Bindable(v, i));
86
+ // A real binding stores what it was given; the ArrayBuffer→Uint8Array conversion
87
+ // here is `bun:sqlite`'s requirement, not D1's, and happens below the emulated edge.
88
+ const stored = values.map((v) => (v instanceof ArrayBuffer ? new Uint8Array(v) : v));
89
+ return new FakeD1Statement(this.db, this.sql, stored);
90
+ }
91
+
92
+ private meta(started: number, changes = 0, lastRowId = 0) {
93
+ return {
94
+ duration: performance.now() - started,
95
+ changes,
96
+ last_row_id: lastRowId,
97
+ // D1 reports these; the numbers here are not meaningful, but their PRESENCE is
98
+ // what the driver's `meta` mapping has to cope with.
99
+ rows_read: 0,
100
+ rows_written: changes,
101
+ served_by: 'fake-d1',
102
+ };
103
+ }
104
+
105
+ async first(column?: string): Promise<unknown> {
106
+ assertRunnableOnD1(this.sql);
107
+ const row = this.db.query(this.sql).get(...(this.params as never[])) as Record<string, unknown> | null;
108
+ if (!row) return null;
109
+ const wire = toWireRow(row);
110
+ if (column === undefined) return wire;
111
+ if (!(column in wire)) throw new Error(`D1_ERROR: no such column: ${column}`);
112
+ return wire[column];
113
+ }
114
+
115
+ async all(): Promise<D1BindingResult> {
116
+ assertRunnableOnD1(this.sql);
117
+ const started = performance.now();
118
+ const rows = this.db.query(this.sql).all(...(this.params as never[])) as Record<string, unknown>[];
119
+ return { results: rows.map(toWireRow), success: true, meta: this.meta(started) };
120
+ }
121
+
122
+ async run(): Promise<D1BindingResult> {
123
+ assertRunnableOnD1(this.sql);
124
+ const started = performance.now();
125
+ const res = this.db.query(this.sql).run(...(this.params as never[]));
126
+ return {
127
+ results: [],
128
+ success: true,
129
+ meta: this.meta(started, res.changes, Number(res.lastInsertRowid)),
130
+ };
131
+ }
132
+
133
+ async raw(): Promise<unknown[][]> {
134
+ assertRunnableOnD1(this.sql);
135
+ const rows = this.db.query(this.sql).values(...(this.params as never[])) as unknown[][];
136
+ return rows.map((r) => r.map(toWireValue));
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Wrap a real `bun:sqlite` database in D1's API and D1's restrictions.
142
+ *
143
+ * ```ts
144
+ * const sqlite = new Database(':memory:');
145
+ * const db = createRemoteD1(createFakeD1Binding(sqlite)); // behaves like the real thing
146
+ * ```
147
+ */
148
+ export function createFakeD1Binding(db: Database): D1BindingLike {
149
+ return {
150
+ prepare(sql: string): D1BindingStatement {
151
+ return new FakeD1Statement(db, sql);
152
+ },
153
+
154
+ async batch(statements: D1BindingStatement[]): Promise<D1BindingResult[]> {
155
+ // D1's batch is atomic — it is the only atomicity D1 offers. A real `bun:sqlite`
156
+ // transaction is the faithful local equivalent.
157
+ const results: D1BindingResult[] = [];
158
+ const run = db.transaction((stmts: D1BindingStatement[]) => {
159
+ for (const s of stmts) {
160
+ const started = performance.now();
161
+ const inner = s as FakeD1Statement;
162
+ assertRunnableOnD1(inner.sql);
163
+ const rows = db.query(inner.sql).all(...(inner.params as never[])) as Record<string, unknown>[];
164
+ results.push({
165
+ results: rows.map(toWireRow),
166
+ success: true,
167
+ meta: {
168
+ duration: performance.now() - started,
169
+ changes: 0,
170
+ last_row_id: 0,
171
+ rows_read: rows.length,
172
+ rows_written: 0,
173
+ served_by: 'fake-d1',
174
+ },
175
+ });
176
+ }
177
+ });
178
+ run(statements);
179
+ return results;
180
+ },
181
+
182
+ async exec(sql: string): Promise<{ count: number; duration: number }> {
183
+ const started = performance.now();
184
+ const statements = sql
185
+ .split(';')
186
+ .map((s) => s.trim())
187
+ .filter((s) => s.length > 0);
188
+ for (const s of statements) assertRunnableOnD1(s);
189
+ for (const s of statements) db.exec(s);
190
+ return { count: statements.length, duration: performance.now() - started };
191
+ },
192
+ };
193
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `cursedbelt-server/d1` — the async database seam the fleet crosses to reach Cloudflare
3
+ * Workers, with a local `bun:sqlite` implementation so the gate, the dev loop and every
4
+ * still-Mac-hosted app keep working unchanged.
5
+ *
6
+ * Start at `./types`, which explains why the interface is async on BOTH sides and why the
7
+ * local driver is deliberately as strict as D1 rather than as lenient as Bun.
8
+ *
9
+ * ```ts
10
+ * import { createLocalD1, createRemoteD1, createD1Kysely } from 'cursedbelt-server/d1';
11
+ *
12
+ * const db = env.DB ? createRemoteD1(env.DB) : createLocalD1(sqlite);
13
+ * const row = await db.prepare('SELECT * FROM people WHERE id = ?').bind(id).first();
14
+ * const ky = createD1Kysely(db);
15
+ * ```
16
+ */
17
+
18
+ export {
19
+ backupFor,
20
+ type BackupPoint,
21
+ checkpointWal,
22
+ createLocalBackup,
23
+ createTimeTravelBackup,
24
+ type DatabaseBackup,
25
+ type LocalBackupOpts,
26
+ type TimeTravelOpts,
27
+ } from './backup';
28
+ export { createD1Kysely, D1LikeDialect, type D1PlumbingDb } from './kysely';
29
+ export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits';
30
+ export { createLocalD1, refuseInteractiveTransaction } from './local';
31
+ export {
32
+ createRemoteD1,
33
+ type D1BindingLike,
34
+ type D1BindingMeta,
35
+ type D1BindingResult,
36
+ type D1BindingStatement,
37
+ } from './remote';
38
+ export {
39
+ classifySchedule,
40
+ cronFieldCount,
41
+ jobsForCron,
42
+ type PortableJob,
43
+ type PortableJobContext,
44
+ type ScheduleVerdict,
45
+ toCronTriggers,
46
+ toFiveField,
47
+ type TriggerShape,
48
+ type WorkersPrimitive,
49
+ } from './scheduling';
50
+ export {
51
+ D1BindError,
52
+ type D1LikeBindable,
53
+ type D1LikeDatabase,
54
+ type D1LikeMeta,
55
+ type D1LikeResult,
56
+ type D1LikeRow,
57
+ type D1LikeStatement,
58
+ type D1LikeValue,
59
+ D1LimitError,
60
+ D1UnsupportedError,
61
+ } from './types';
62
+ export { makeMeta, normalizeBind, normalizeBinds, normalizeRow, normalizeRows, normalizeValue } from './values';
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The cheap half, proved on BOTH drivers with the same assertions — the point being that
3
+ * `createD1Kysely` is one dialect over the driver seam, so a plumbing query cannot tell
4
+ * which side it is on.
5
+ */
6
+
7
+ import { Database } from 'bun:sqlite';
8
+ import { describe, expect, test } from 'bun:test';
9
+ import type { Kysely } from 'kysely';
10
+ import { createFakeD1Binding } from './fakeD1';
11
+ import { createD1Kysely } from './kysely';
12
+ import { createLocalD1 } from './local';
13
+ import { createRemoteD1 } from './remote';
14
+ import { D1UnsupportedError, type D1LikeDatabase } from './types';
15
+
16
+ const DDL = `CREATE TABLE app_config (
17
+ key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL, updated_by TEXT
18
+ )`;
19
+
20
+ interface Schema {
21
+ app_config: { key: string; value: string; updated_at: string; updated_by: string | null };
22
+ }
23
+
24
+ /** The same schema behind each driver, so every test below runs twice. */
25
+ const drivers = (): Array<[string, () => D1LikeDatabase]> => [
26
+ [
27
+ 'local',
28
+ () => {
29
+ const db = new Database(':memory:');
30
+ db.run(DDL);
31
+ return createLocalD1(db);
32
+ },
33
+ ],
34
+ [
35
+ 'd1',
36
+ () => {
37
+ const db = new Database(':memory:');
38
+ db.run(DDL);
39
+ return createRemoteD1(createFakeD1Binding(db));
40
+ },
41
+ ],
42
+ ];
43
+
44
+ for (const [flavor, make] of drivers()) {
45
+ describe(`createD1Kysely on the ${flavor} driver`, () => {
46
+ const fresh = (): Kysely<Schema> => createD1Kysely<Schema>(make());
47
+
48
+ test('selectAll on an empty table returns []', async () => {
49
+ expect(await fresh().selectFrom('app_config').selectAll().execute()).toEqual([]);
50
+ });
51
+
52
+ test('insert + select round-trips a row', async () => {
53
+ const ky = fresh();
54
+ await ky
55
+ .insertInto('app_config')
56
+ .values({ key: 'media_backend', value: 'disk', updated_at: 't', updated_by: null })
57
+ .execute();
58
+
59
+ const row = await ky
60
+ .selectFrom('app_config')
61
+ .select(['key', 'value'])
62
+ .where('key', '=', 'media_backend')
63
+ .executeTakeFirst();
64
+
65
+ expect(row).toEqual({ key: 'media_backend', value: 'disk' });
66
+ });
67
+
68
+ test('an insert reports the rows it affected', async () => {
69
+ const ky = fresh();
70
+ const res = await ky
71
+ .insertInto('app_config')
72
+ .values({ key: 'a', value: '1', updated_at: 't', updated_by: null })
73
+ .executeTakeFirst();
74
+ expect(Number(res.numInsertedOrUpdatedRows)).toBe(1);
75
+ });
76
+
77
+ test('update and delete report their affected row counts', async () => {
78
+ const ky = fresh();
79
+ await ky
80
+ .insertInto('app_config')
81
+ .values({ key: 'a', value: '1', updated_at: 't', updated_by: null })
82
+ .execute();
83
+
84
+ const updated = await ky
85
+ .updateTable('app_config')
86
+ .set({ value: '2' })
87
+ .where('key', '=', 'a')
88
+ .executeTakeFirst();
89
+ expect(Number(updated.numUpdatedRows)).toBe(1);
90
+
91
+ const deleted = await ky.deleteFrom('app_config').where('key', '=', 'a').executeTakeFirst();
92
+ expect(Number(deleted.numDeletedRows)).toBe(1);
93
+ });
94
+
95
+ test('RETURNING gives back rows rather than a count', async () => {
96
+ const ky = fresh();
97
+ const returned = await ky
98
+ .insertInto('app_config')
99
+ .values({ key: 'k', value: 'v', updated_at: 't', updated_by: null })
100
+ .returning(['key', 'value'])
101
+ .executeTakeFirst();
102
+ expect(returned).toEqual({ key: 'k', value: 'v' });
103
+ });
104
+
105
+ test('a NULL column comes back as null on both', async () => {
106
+ const ky = fresh();
107
+ await ky
108
+ .insertInto('app_config')
109
+ .values({ key: 'a', value: '1', updated_at: 't', updated_by: null })
110
+ .execute();
111
+ const row = await ky.selectFrom('app_config').selectAll().executeTakeFirstOrThrow();
112
+ expect(row.updated_by).toBeNull();
113
+ });
114
+
115
+ test('🔴 an interactive transaction is REFUSED — D1 has none, so neither does local', async () => {
116
+ // The trap this closes: a local dialect that honoured BEGIN would be green here
117
+ // and red in the Worker. `db.batch()` is the atomicity primitive instead.
118
+ const ky = fresh();
119
+ await expect(
120
+ ky.transaction().execute(async (trx) => {
121
+ await trx
122
+ .insertInto('app_config')
123
+ .values({ key: 'x', value: '1', updated_at: 't', updated_by: null })
124
+ .execute();
125
+ }),
126
+ ).rejects.toThrow(D1UnsupportedError);
127
+ });
128
+
129
+ test('a refused transaction leaves NOTHING behind', async () => {
130
+ const db = make();
131
+ const ky = createD1Kysely<Schema>(db);
132
+ await ky
133
+ .transaction()
134
+ .execute(async (trx) => {
135
+ await trx
136
+ .insertInto('app_config')
137
+ .values({ key: 'x', value: '1', updated_at: 't', updated_by: null })
138
+ .execute();
139
+ })
140
+ .catch(() => {});
141
+ const rows = await ky.selectFrom('app_config').selectAll().execute();
142
+ expect(rows).toEqual([]);
143
+ });
144
+ });
145
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The cheap half: a Kysely dialect that speaks {@link D1LikeDatabase}, so every session,
3
+ * config, log, metric and job table crosses the seam at once and the schema types do not
4
+ * change at all.
5
+ *
6
+ * ## 🔴 ONE dialect over the driver, not two dialects side by side
7
+ *
8
+ * The obvious port is "swap `BunSqliteDialect` for a D1 dialect behind a factory". It is
9
+ * the wrong shape, and cheaply so: two dialects are two implementations of value handling,
10
+ * and the identical-shapes test would then be asserting that two third-party packages
11
+ * happen to agree about BLOBs, booleans and `undefined` — the four places SQLite drivers
12
+ * are already known to disagree. Here there is one dialect. It talks to the driver seam,
13
+ * the driver seam normalizes through `./values`, and "local and remote return the same
14
+ * shape" is true by construction rather than by luck.
15
+ *
16
+ * It also means the D1 half needs no `kysely-d1` dependency, and the local half keeps
17
+ * working with the `bun:sqlite` handle the rest of a half-ported app still holds.
18
+ *
19
+ * ## What a caller gives up
20
+ *
21
+ * `ky.transaction()` throws {@link D1UnsupportedError} on BOTH drivers — D1 has no
22
+ * interactive transaction, and a local dialect that honoured `BEGIN` would be green here
23
+ * and red in the Worker. Use `db.batch()` for atomicity. Streaming is refused for the same
24
+ * reason: D1 returns a whole result set.
25
+ *
26
+ * The existing `../db/kysely.ts` is untouched and still correct for an app that has not
27
+ * moved; this is the one to reach for when it does.
28
+ */
29
+
30
+ import {
31
+ type CompiledQuery,
32
+ type DatabaseConnection,
33
+ type DatabaseIntrospector,
34
+ type Dialect,
35
+ type DialectAdapter,
36
+ type Driver,
37
+ Kysely,
38
+ type QueryCompiler,
39
+ type QueryResult,
40
+ SqliteAdapter,
41
+ SqliteIntrospector,
42
+ SqliteQueryCompiler,
43
+ } from 'kysely';
44
+ import type { PlumbingSchema } from '../db/plumbingTypes';
45
+ import { refuseInteractiveTransaction } from './local';
46
+ import { type D1LikeBindable, type D1LikeDatabase, D1UnsupportedError } from './types';
47
+
48
+ /**
49
+ * Kysely compiles to a parameter list that may contain values our seam refuses
50
+ * (`undefined` from an optional column, a `Date` from a careless caller). Passing them
51
+ * through `bind()` is what turns those into a named error at the call site instead of a
52
+ * NULL locally and a 500 in production.
53
+ */
54
+ const bindParams = (sql: string, db: D1LikeDatabase, parameters: readonly unknown[]) => {
55
+ const stmt = db.prepare(sql);
56
+ return parameters.length > 0 ? stmt.bind(...(parameters as D1LikeBindable[])) : stmt;
57
+ };
58
+
59
+ /**
60
+ * Decide whether a compiled query should be read (`.all()`) or executed (`.run()`).
61
+ *
62
+ * Kysely needs `numAffectedRows`/`insertId` from a write and rows from a read, and only
63
+ * `.run()` reports the former. A write carrying `RETURNING` is a read for this purpose —
64
+ * its rows are the whole point of the statement.
65
+ */
66
+ function isRowReturning(query: CompiledQuery['query']): boolean {
67
+ const node = query as { kind?: string; returning?: unknown };
68
+ if (node.kind === 'SelectQueryNode' || node.kind === 'RawNode') return true;
69
+ return node.returning !== undefined;
70
+ }
71
+
72
+ class D1LikeConnection implements DatabaseConnection {
73
+ constructor(private readonly db: D1LikeDatabase) {}
74
+
75
+ async executeQuery<R>(compiled: CompiledQuery): Promise<QueryResult<R>> {
76
+ const stmt = bindParams(compiled.sql, this.db, compiled.parameters);
77
+
78
+ if (isRowReturning(compiled.query)) {
79
+ const res = await stmt.all<R>();
80
+ return { rows: res.results };
81
+ }
82
+
83
+ const res = await stmt.run();
84
+ return {
85
+ rows: [],
86
+ numAffectedRows: BigInt(res.meta.changes),
87
+ insertId: res.meta.last_row_id === null ? undefined : BigInt(res.meta.last_row_id),
88
+ };
89
+ }
90
+
91
+ // biome-ignore lint/correctness/useYield: it always throws; a generator body with no
92
+ // yield is the honest shape for an operation the seam does not have.
93
+ async *streamQuery<R>(): AsyncIterableIterator<QueryResult<R>> {
94
+ throw new D1UnsupportedError(
95
+ 'streaming a result set',
96
+ 'D1 returns a whole result set — page with LIMIT/OFFSET, or a keyset cursor',
97
+ );
98
+ }
99
+ }
100
+
101
+ class D1LikeDriver implements Driver {
102
+ constructor(private readonly db: D1LikeDatabase) {}
103
+
104
+ async init(): Promise<void> {}
105
+
106
+ async acquireConnection(): Promise<DatabaseConnection> {
107
+ // A D1 binding is not a connection: there is no pool, nothing to check out and
108
+ // nothing to close. The local driver shares the app's single handle for the same
109
+ // reason. So one connection object serves every query.
110
+ return new D1LikeConnection(this.db);
111
+ }
112
+
113
+ async beginTransaction(): Promise<void> {
114
+ refuseInteractiveTransaction();
115
+ }
116
+
117
+ async commitTransaction(): Promise<void> {
118
+ refuseInteractiveTransaction();
119
+ }
120
+
121
+ async rollbackTransaction(): Promise<void> {
122
+ refuseInteractiveTransaction();
123
+ }
124
+
125
+ async releaseConnection(): Promise<void> {}
126
+
127
+ async destroy(): Promise<void> {}
128
+ }
129
+
130
+ /** The dialect itself — SQLite's adapter, compiler and introspector over our driver. */
131
+ export class D1LikeDialect implements Dialect {
132
+ constructor(private readonly db: D1LikeDatabase) {}
133
+
134
+ createAdapter(): DialectAdapter {
135
+ return new SqliteAdapter();
136
+ }
137
+
138
+ createDriver(): Driver {
139
+ return new D1LikeDriver(this.db);
140
+ }
141
+
142
+ createQueryCompiler(): QueryCompiler {
143
+ return new SqliteQueryCompiler();
144
+ }
145
+
146
+ createIntrospector(db: Kysely<unknown>): DatabaseIntrospector {
147
+ return new SqliteIntrospector(db);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * A Kysely query builder over either driver.
153
+ *
154
+ * Defaults to {@link PlumbingSchema} — sessions, config, logs, metrics and jobs — which is
155
+ * the boundary `../db/kysely.ts` drew and this keeps: business / `data JSON` tables stay
156
+ * on the raw statement API, so the Kysely schema never grows a row per feature table.
157
+ * Apps with their own plumbing pass their own schema.
158
+ *
159
+ * ```ts
160
+ * const db = createLocalD1(sqlite); // or createRemoteD1(env.DB)
161
+ * const ky = createD1Kysely(db);
162
+ * await ky.selectFrom('auth_sessions').selectAll().execute(); // identical on both
163
+ * ```
164
+ */
165
+ export function createD1Kysely<Schema = PlumbingSchema>(db: D1LikeDatabase): Kysely<Schema> {
166
+ return new Kysely<Schema>({ dialect: new D1LikeDialect(db) });
167
+ }
168
+
169
+ export type D1PlumbingDb = Kysely<PlumbingSchema>;
@@ -0,0 +1,90 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { describe, expect, test } from 'bun:test';
3
+ import { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits';
4
+ import { createLocalD1 } from './local';
5
+ import { D1LimitError } from './types';
6
+
7
+ describe('D1 limits are enforced on the LOCAL driver too', () => {
8
+ // 🔴 The point of every test here: `bun:sqlite` has none of these caps, so without
9
+ // this enforcement a bulk insert is green on the laptop and fails on the first real
10
+ // import in production.
11
+
12
+ test('101 bound parameters is refused, naming the chunking helper', () => {
13
+ const db = createLocalD1(new Database(':memory:'));
14
+ const placeholders = Array(101).fill('?').join(',');
15
+ expect(() => db.prepare(`SELECT * FROM t WHERE id IN (${placeholders})`).bind(...Array(101).fill(1))).toThrow(
16
+ D1LimitError,
17
+ );
18
+ try {
19
+ db.prepare(`SELECT * FROM t WHERE id IN (${placeholders})`).bind(...Array(101).fill(1));
20
+ } catch (e) {
21
+ expect((e as Error).message).toContain('chunkForBind');
22
+ }
23
+ });
24
+
25
+ test('exactly 100 bound parameters is allowed — the cap is not off by one', () => {
26
+ const db = new Database(':memory:');
27
+ db.run('CREATE TABLE t (id INTEGER)');
28
+ const seam = createLocalD1(db);
29
+ const placeholders = Array(100).fill('?').join(',');
30
+ expect(() =>
31
+ seam.prepare(`SELECT * FROM t WHERE id IN (${placeholders})`).bind(...Array(100).fill(1)),
32
+ ).not.toThrow();
33
+ });
34
+
35
+ test('a value over 2 MB is refused, and says where media belongs instead', () => {
36
+ const db = createLocalD1(new Database(':memory:'));
37
+ const huge = 'x'.repeat(LIMITS.valueBytes + 1);
38
+ try {
39
+ db.prepare('INSERT INTO t (v) VALUES (?)').bind(huge);
40
+ throw new Error('expected a refusal');
41
+ } catch (e) {
42
+ expect(e).toBeInstanceOf(D1LimitError);
43
+ expect((e as Error).message).toContain('R2');
44
+ }
45
+ });
46
+
47
+ test('SQL longer than 100 KB is refused before the parameter cap bites', () => {
48
+ // The measured ordering: a generated `IN (...)` list hits the statement-length cap
49
+ // first, because inlined values carry no parameters at all.
50
+ const sql = `SELECT * FROM t WHERE x IN (${Array(30_000).fill("'abc'").join(',')})`;
51
+ expect(() => assertWithinLimits(sql, [])).toThrow(D1LimitError);
52
+ expect(() => assertWithinLimits(sql, [])).toThrow(/SQL statement length/);
53
+ });
54
+
55
+ test('a batch over 1,000 statements is refused', () => {
56
+ expect(() => assertBatchSize(1_001)).toThrow(D1LimitError);
57
+ expect(() => assertBatchSize(1_000)).not.toThrow();
58
+ });
59
+
60
+ describe('chunkForBind does the arithmetic nobody does correctly by eye', () => {
61
+ test('4 columns → 25 rows a chunk', () => {
62
+ const chunks = chunkForBind(Array.from({ length: 60 }, (_, i) => i), 4);
63
+ expect(chunks.map((c) => c.length)).toEqual([25, 25, 10]);
64
+ // The property that matters: no chunk exceeds the cap once expanded.
65
+ for (const c of chunks) expect(c.length * 4).toBeLessThanOrEqual(LIMITS.boundParams);
66
+ });
67
+
68
+ test('a chunk is never empty and every item appears exactly once', () => {
69
+ const items = Array.from({ length: 489 }, (_, i) => i); // family's 489 people
70
+ const chunks = chunkForBind(items, 7);
71
+ expect(chunks.flat()).toEqual(items);
72
+ for (const c of chunks) expect(c.length).toBeGreaterThan(0);
73
+ });
74
+
75
+ test('an empty list yields no chunks rather than one empty one', () => {
76
+ expect(chunkForBind([], 3)).toEqual([]);
77
+ });
78
+
79
+ test('an item wider than the cap is refused, not silently chunked to zero', () => {
80
+ // `floor(100 / 101)` is 0, and a chunk size of 0 loops for ever. This is the
81
+ // guard that turns an infinite loop into a sentence.
82
+ expect(() => chunkForBind([1, 2], 101)).toThrow(D1LimitError);
83
+ });
84
+
85
+ test('a nonsense chunk width is refused', () => {
86
+ expect(() => chunkForBind([1], 0)).toThrow(RangeError);
87
+ expect(() => chunkForBind([1], 1.5)).toThrow(RangeError);
88
+ });
89
+ });
90
+ });