cursedbelt-server 1.1.0 → 2.1.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 (81) 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/sync/http.d.ts +20 -3
  36. package/dist/server/sync/http.js +20 -14
  37. package/dist/server/sync/index.d.ts +10 -2
  38. package/dist/server/sync/index.js +9 -1
  39. package/dist/server/sync/planner.d.ts +38 -8
  40. package/dist/server/sync/planner.js +32 -8
  41. package/dist/server/sync/signal.d.ts +161 -0
  42. package/dist/server/sync/signal.js +348 -0
  43. package/dist/server/sync/timer.d.ts +63 -19
  44. package/dist/server/sync/timer.js +104 -45
  45. package/dist/server/sync/types.d.ts +0 -2
  46. package/package.json +21 -3
  47. package/src/leafSubpathsImportNothing.spec.ts +15 -3
  48. package/src/noTimerDialsAPeer.spec.ts +469 -0
  49. package/src/server/bench/assert.ts +192 -0
  50. package/src/server/bench/budget.spec.ts +126 -0
  51. package/src/server/bench/budget.ts +207 -0
  52. package/src/server/bench/cpuBudget.spec.ts +302 -0
  53. package/src/server/bench/cpuBudget.ts +81 -0
  54. package/src/server/bench/cpuClock.ts +119 -0
  55. package/src/server/bench/index.ts +81 -0
  56. package/src/server/bench/recorder.ts +163 -0
  57. package/src/server/bench/runBench.ts +110 -0
  58. package/src/server/d1/backup.spec.ts +121 -0
  59. package/src/server/d1/backup.ts +186 -0
  60. package/src/server/d1/fakeD1.ts +193 -0
  61. package/src/server/d1/index.ts +62 -0
  62. package/src/server/d1/kysely.spec.ts +145 -0
  63. package/src/server/d1/kysely.ts +169 -0
  64. package/src/server/d1/limits.spec.ts +90 -0
  65. package/src/server/d1/limits.ts +123 -0
  66. package/src/server/d1/local.ts +173 -0
  67. package/src/server/d1/remote.ts +182 -0
  68. package/src/server/d1/sameShape.spec.ts +279 -0
  69. package/src/server/d1/scheduling.spec.ts +120 -0
  70. package/src/server/d1/scheduling.ts +210 -0
  71. package/src/server/d1/types.ts +163 -0
  72. package/src/server/d1/values.ts +138 -0
  73. package/src/server/sync/http.ts +31 -16
  74. package/src/server/sync/index.ts +23 -1
  75. package/src/server/sync/planner.spec.ts +33 -16
  76. package/src/server/sync/planner.ts +48 -11
  77. package/src/server/sync/signal.spec.ts +306 -0
  78. package/src/server/sync/signal.ts +422 -0
  79. package/src/server/sync/timer.spec.ts +97 -16
  80. package/src/server/sync/timer.ts +124 -47
  81. package/src/server/sync/types.ts +0 -2
@@ -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
+ });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * D1's hard limits, and the helpers that keep a query inside them.
3
+ *
4
+ * 🔴 **These shape the schema, not just the bill.** Three of them change how code must be
5
+ * written, and all three are invisible on `bun:sqlite` — which has no such caps, so every
6
+ * one of them is a green-here / red-there defect waiting for the first real dataset:
7
+ *
8
+ * · **1,000 queries per Worker invocation.** An N+1 loop over `family`'s 489 people
9
+ * exceeds it. Join, or `batch()`; do not loop.
10
+ * · **100 bound parameters per query.** Any seed, import or bulk insert hits this. Use
11
+ * {@link chunkForBind}, which does the arithmetic rather than leaving it to a guess.
12
+ * · **2 MB per string / BLOB / row.** Nothing may store a media byte in D1.
13
+ *
14
+ * The limits are asserted by the LOCAL driver as well as the remote one, which is the
15
+ * whole point: a bulk insert that would fail in production fails on the laptop, at the
16
+ * call site, with the chunk size it should have used in the message.
17
+ *
18
+ * Values are the Workers **Paid** tier, which is what the owner already pays for.
19
+ */
20
+
21
+ import { D1LimitError, type D1LikeBindable } from './types';
22
+
23
+ export const LIMITS = {
24
+ /** Bound parameters in a single statement. */
25
+ boundParams: 100,
26
+ /** Queries a single Worker invocation may issue, including every `batch()` member. */
27
+ queriesPerInvocation: 1_000,
28
+ /** Bytes of SQL text in one statement. A generated `IN (…)` list hits this first. */
29
+ sqlLength: 100_000,
30
+ /** Bytes in any single string or BLOB value, and in any single row. */
31
+ valueBytes: 2_000_000,
32
+ /** Columns in one table. The fleet's `data JSON` pattern stays far under it. */
33
+ columnsPerTable: 100,
34
+ /** Wall-clock milliseconds one query may take. */
35
+ queryDurationMs: 30_000,
36
+ } as const;
37
+
38
+ const byteLength = (v: D1LikeBindable): number => {
39
+ if (typeof v === 'string') return Buffer.byteLength(v, 'utf8');
40
+ if (v instanceof Uint8Array) return v.byteLength;
41
+ if (v instanceof ArrayBuffer) return v.byteLength;
42
+ return 0;
43
+ };
44
+
45
+ /**
46
+ * Refuse a statement that D1 would refuse — checked on BOTH drivers, at `bind()` time, so
47
+ * the failure lands on the call site that built the query rather than in production.
48
+ */
49
+ export function assertWithinLimits(sql: string, params: readonly D1LikeBindable[]): void {
50
+ if (params.length > LIMITS.boundParams) {
51
+ throw new D1LimitError(
52
+ 'bound parameters per query',
53
+ params.length,
54
+ LIMITS.boundParams,
55
+ 'Chunk the work — `chunkForBind(items, paramsPerItem)` sizes the batches for you, ' +
56
+ 'or use `batch()` for independent statements.',
57
+ );
58
+ }
59
+ const sqlBytes = Buffer.byteLength(sql, 'utf8');
60
+ if (sqlBytes > LIMITS.sqlLength) {
61
+ throw new D1LimitError(
62
+ 'SQL statement length (bytes)',
63
+ sqlBytes,
64
+ LIMITS.sqlLength,
65
+ 'A generated `IN (...)` list hits this before the parameter cap — bind parameters instead of inlining values.',
66
+ );
67
+ }
68
+ for (let i = 0; i < params.length; i++) {
69
+ const bytes = byteLength(params[i] as D1LikeBindable);
70
+ if (bytes > LIMITS.valueBytes) {
71
+ throw new D1LimitError(
72
+ `value size for parameter ${i + 1} (bytes)`,
73
+ bytes,
74
+ LIMITS.valueBytes,
75
+ 'D1 is not a blob store. Media bytes belong in R2 behind the binary server — see task 193.',
76
+ );
77
+ }
78
+ }
79
+ }
80
+
81
+ /** Refuse a `batch()` bigger than one Worker invocation may issue. */
82
+ export function assertBatchSize(count: number): void {
83
+ if (count > LIMITS.queriesPerInvocation) {
84
+ throw new D1LimitError(
85
+ 'queries per Worker invocation',
86
+ count,
87
+ LIMITS.queriesPerInvocation,
88
+ 'Split the work across invocations — a Queue consumer or a Cron Trigger, not one giant batch.',
89
+ );
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Split a list into chunks that fit under the 100-parameter cap, given how many bound
95
+ * parameters each item contributes.
96
+ *
97
+ * A bulk insert of `n` rows with `c` columns binds `n * c` parameters, so the batch size
98
+ * is `floor(100 / c)` — the arithmetic nobody does correctly by eye at 3 a.m., which is
99
+ * why it is a function and not a sentence in a doc.
100
+ *
101
+ * ```ts
102
+ * for (const rows of chunkForBind(people, 4)) { // 4 columns → 25 rows a chunk
103
+ * await db.prepare(insertSql(rows.length)).bind(...rows.flatMap(toParams)).run();
104
+ * }
105
+ * ```
106
+ */
107
+ export function chunkForBind<T>(items: readonly T[], paramsPerItem: number): T[][] {
108
+ if (!Number.isInteger(paramsPerItem) || paramsPerItem < 1) {
109
+ throw new RangeError(`paramsPerItem must be a positive integer, got ${paramsPerItem}`);
110
+ }
111
+ if (paramsPerItem > LIMITS.boundParams) {
112
+ throw new D1LimitError(
113
+ 'bound parameters per query',
114
+ paramsPerItem,
115
+ LIMITS.boundParams,
116
+ 'A single item already exceeds the cap — store fewer columns, or move the wide value out of D1.',
117
+ );
118
+ }
119
+ const size = Math.floor(LIMITS.boundParams / paramsPerItem);
120
+ const out: T[][] = [];
121
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size) as T[]);
122
+ return out;
123
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The LOCAL implementation of {@link D1LikeDatabase}, over `bun:sqlite`.
3
+ *
4
+ * This is the half that keeps the gate, the dev loop and a Mac-hosted app working
5
+ * unchanged while the fleet ports. It is async because the seam is async — not because
6
+ * anything here awaits I/O. `bun:sqlite` answers synchronously and these methods resolve
7
+ * an already-computed value, so the cost is a microtask, not a round trip.
8
+ *
9
+ * 🔴 **It is deliberately no more permissive than D1.** Every bind goes through
10
+ * `normalizeBind`, which refuses the two values `bun:sqlite` would have accepted silently
11
+ * (`undefined`, and out-of-range `bigint`) and coerces the one both can agree on
12
+ * (`boolean`). D1's limits are asserted here too, and an interactive transaction is
13
+ * refused here exactly as it is refused remotely. The point of a local driver is to fail
14
+ * the same way, early and on a laptop.
15
+ */
16
+
17
+ import type { Database } from 'bun:sqlite';
18
+ import { assertBatchSize, assertWithinLimits } from './limits';
19
+ import {
20
+ type D1LikeBindable,
21
+ type D1LikeDatabase,
22
+ type D1LikeResult,
23
+ type D1LikeRow,
24
+ type D1LikeStatement,
25
+ type D1LikeValue,
26
+ D1UnsupportedError,
27
+ } from './types';
28
+ import { makeMeta, normalizeBinds, normalizeRows, normalizeValue } from './values';
29
+
30
+ /** Shared clock, so `meta.duration` means the same thing on both sides of the seam. */
31
+ const now = (): number => performance.now();
32
+
33
+ /**
34
+ * 🔴 The synchronous core, kept separate from the async surface on purpose.
35
+ *
36
+ * `batch()` has to run every statement inside one `db.transaction()` callback, and that
37
+ * callback is synchronous — a promise resolved inside it settles in a later microtask,
38
+ * long after `transaction()` has already committed. So the statements expose a real
39
+ * synchronous method that `batch()` calls directly, and the public `all()` merely wraps
40
+ * it. Reaching for `.then()` inside the transaction callback looks like it works and
41
+ * silently commits an empty transaction.
42
+ */
43
+ interface SyncExecutable {
44
+ allSync<T>(): D1LikeResult<T>;
45
+ }
46
+
47
+ class LocalStatement implements D1LikeStatement, SyncExecutable {
48
+ constructor(
49
+ private readonly db: Database,
50
+ private readonly sql: string,
51
+ private readonly params: D1LikeBindable[] = [],
52
+ ) {}
53
+
54
+ bind(...values: D1LikeBindable[]): D1LikeStatement {
55
+ const params = normalizeBinds(values);
56
+ assertWithinLimits(this.sql, params);
57
+ return new LocalStatement(this.db, this.sql, params);
58
+ }
59
+
60
+ private stmt() {
61
+ return this.db.query(this.sql);
62
+ }
63
+
64
+ allSync<T = D1LikeRow>(): D1LikeResult<T> {
65
+ const started = now();
66
+ const rows = this.stmt().all(...(this.params as never[])) as Record<string, unknown>[];
67
+ return {
68
+ results: normalizeRows<T>(rows),
69
+ success: true,
70
+ meta: makeMeta({ duration: now() - started }),
71
+ };
72
+ }
73
+
74
+ async first<T = D1LikeRow>(column?: string): Promise<T | null> {
75
+ const row = this.stmt().get(...(this.params as never[])) as Record<string, unknown> | null;
76
+ if (row === null || row === undefined) return null;
77
+ if (column === undefined) return normalizeRows<T>([row])[0] ?? null;
78
+ if (!(column in row)) {
79
+ throw new RangeError(`column '${column}' is not in the result of: ${this.sql.slice(0, 80)}`);
80
+ }
81
+ return normalizeValue(row[column]) as T;
82
+ }
83
+
84
+ async all<T = D1LikeRow>(): Promise<D1LikeResult<T>> {
85
+ return this.allSync<T>();
86
+ }
87
+
88
+ async run(): Promise<D1LikeResult<never>> {
89
+ const started = now();
90
+ const res = this.stmt().run(...(this.params as never[]));
91
+ return {
92
+ results: [],
93
+ success: true,
94
+ meta: makeMeta({
95
+ changes: res.changes,
96
+ last_row_id: res.lastInsertRowid,
97
+ duration: now() - started,
98
+ }),
99
+ };
100
+ }
101
+
102
+ async raw<V = D1LikeValue>(): Promise<V[][]> {
103
+ const rows = this.stmt().values(...(this.params as never[])) as unknown[][];
104
+ return rows.map((r) => r.map((v) => normalizeValue(v) as V));
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Wrap a `bun:sqlite` {@link Database} in the async seam.
110
+ *
111
+ * The handle is the SAME one a not-yet-ported app still uses directly, so a half-ported
112
+ * app has one connection, one WAL and one transaction space — the property `createKysely`
113
+ * was written for. That is what makes a file-at-a-time port possible instead of an
114
+ * app-at-a-time one, and it is why this takes a `Database` rather than a path.
115
+ */
116
+ export function createLocalD1(db: Database): D1LikeDatabase {
117
+ return {
118
+ flavor: 'local',
119
+
120
+ prepare(sql: string): D1LikeStatement {
121
+ return new LocalStatement(db, sql);
122
+ },
123
+
124
+ /**
125
+ * Atomic on both sides. Locally that is a real `bun:sqlite` transaction; on D1 it is
126
+ * one round trip the service commits or discards as a unit. The guarantee a caller
127
+ * can rely on is the same, which is the only reason `batch()` can be the fleet's
128
+ * transaction primitive — see the note on {@link SyncExecutable} for why the body
129
+ * below may not await.
130
+ */
131
+ async batch<T = D1LikeRow>(statements: D1LikeStatement[]): Promise<D1LikeResult<T>[]> {
132
+ assertBatchSize(statements.length);
133
+ const run = db.transaction((stmts: D1LikeStatement[]): D1LikeResult<T>[] => {
134
+ const out: D1LikeResult<T>[] = [];
135
+ for (const s of stmts) {
136
+ const sync = s as Partial<SyncExecutable>;
137
+ if (typeof sync.allSync !== 'function') {
138
+ throw new TypeError(
139
+ 'batch() accepts only statements from this same local database — ' +
140
+ 'a remote statement cannot run inside a local transaction.',
141
+ );
142
+ }
143
+ out.push(sync.allSync<T>());
144
+ }
145
+ return out;
146
+ });
147
+ return run(statements);
148
+ },
149
+
150
+ async exec(sql: string): Promise<{ count: number; duration: number }> {
151
+ const started = now();
152
+ db.exec(sql);
153
+ // D1's `exec` reports how many statements it ran, and counts them by `;`.
154
+ // Matching that keeps the field meaning one thing on both sides.
155
+ const count = sql
156
+ .split(';')
157
+ .map((s) => s.trim())
158
+ .filter((s) => s.length > 0).length;
159
+ return { count, duration: now() - started };
160
+ },
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Refuse an interactive transaction, for the same reason D1 refuses it. Exported so the
166
+ * Kysely dialect and any app-level helper give one message instead of three.
167
+ */
168
+ export const refuseInteractiveTransaction = (): never => {
169
+ throw new D1UnsupportedError(
170
+ 'an interactive transaction (BEGIN/COMMIT)',
171
+ 'collect the statements and pass them to `db.batch([...])`, which is atomic on both drivers',
172
+ );
173
+ };