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,128 @@
1
+ /**
2
+ * The backup seam โ€” `PRAGMA wal_checkpoint(TRUNCATE)` locally, Time Travel remotely.
3
+ *
4
+ * ## ๐Ÿ”ด BOTH, not one
5
+ *
6
+ * D1 has no WAL a caller can checkpoint, and 30 days of free point-in-time restore is
7
+ * strictly better than the file copy it replaces. It is tempting to read that as "the
8
+ * checkpoint goes away". It does not: `PRAGMA wal_checkpoint(TRUNCATE)` is in the backup
9
+ * path of every app in this fleet and it is load-bearing while any of them is still
10
+ * Mac-hosted. Measured previously, `family.sqlite` was **2.2 MB of principal against a
11
+ * 2.1 MB WAL** โ€” an un-checkpointed copy is half a database, and `VACUUM INTO` merging
12
+ * the WAL is the only reason the existing `../sqlite/backup.ts` is safe.
13
+ *
14
+ * So the local implementation keeps checkpointing, the remote one records a restore
15
+ * coordinate, and both answer the same interface. An app's backup job stops caring which
16
+ * side it is on โ€” which is the property that lets the job move before the database does.
17
+ *
18
+ * ## The remote side does not "take" a backup, and that is not a gap
19
+ *
20
+ * Time Travel is automatic and continuous: D1 retains 30 days and restores to any
21
+ * timestamp or bookmark within it. There is nothing to trigger, so
22
+ * {@link createTimeTravelBackup} records the coordinate rather than inventing an API call
23
+ * that does not exist. The coordinate IS the backup โ€” `wrangler d1 time-travel restore`
24
+ * takes a timestamp.
25
+ *
26
+ * ๐Ÿ”ด **`wrangler d1 export` is the off-Cloudflare leg, and a Worker cannot run it.** A
27
+ * Worker has no shell. That leg belongs to a Cron Trigger on this Mac or in CI, which is
28
+ * why {@link BackupPoint.exportCommand} hands back the command rather than running it โ€”
29
+ * the same split as the binary server, where the bytes and the thing that copies them are
30
+ * deliberately not the same process.
31
+ */
32
+ import { existsSync, mkdirSync } from 'node:fs';
33
+ import { join } from 'node:path';
34
+ import { snapshotSqlite } from '../sqlite/backup';
35
+ /**
36
+ * Force the WAL back into the principal database and truncate the log file.
37
+ *
38
+ * `TRUNCATE` โ€” not `PASSIVE` โ€” because `PASSIVE` gives up silently when a reader holds the
39
+ * WAL, leaving a WAL that never drains while every log line says the backup succeeded.
40
+ *
41
+ * ๐Ÿ”ด **`busy` is the only field worth reading, and that is a measured correction.** Under
42
+ * `TRUNCATE` SQLite reports the counters from AFTER the truncation, so a completely
43
+ * successful checkpoint returns `(busy 0, log 0, checkpointed 0)` โ€” measured 2026-09-16,
44
+ * draining a 70,072-byte WAL to zero reported `checkpointed: 0`. Anything asserting
45
+ * `checkpointed > 0` as proof of work is asserting a value that is structurally always
46
+ * zero here. The honest post-condition is the WAL file's SIZE, which is what
47
+ * `backup.spec.ts` checks; the honest error signal is `busy`.
48
+ */
49
+ export function checkpointWal(db) {
50
+ // One row: (busy, log, checkpointed). See the note above on what they mean here.
51
+ const row = db.query('PRAGMA wal_checkpoint(TRUNCATE)').get();
52
+ return {
53
+ busy: (row?.busy ?? 0) === 1,
54
+ logPages: row?.log ?? 0,
55
+ checkpointed: row?.checkpointed ?? 0,
56
+ };
57
+ }
58
+ /**
59
+ * The Mac-hosted implementation: checkpoint, then `VACUUM INTO` a fresh file.
60
+ *
61
+ * `VACUUM INTO` is already transactionally consistent under WAL, so the checkpoint is not
62
+ * what makes the copy correct โ€” it is what keeps the WAL from growing without bound on a
63
+ * database that is written far more often than it is read, which is how a 2.2 MB database
64
+ * came to carry a 2.1 MB WAL.
65
+ */
66
+ export function createLocalBackup(opts) {
67
+ return {
68
+ needsPeriodicCapture: true,
69
+ async capture() {
70
+ const checkpoint = checkpointWal(opts.db);
71
+ if (checkpoint.busy) {
72
+ // Not fatal โ€” `VACUUM INTO` still produces a consistent copy โ€” but it is the
73
+ // signal that the WAL is not draining, and silence here is how it grows.
74
+ console.warn(`[d1/backup] wal_checkpoint(TRUNCATE) reported BUSY for ${opts.sourcePath} โ€” ` +
75
+ `${checkpoint.logPages} page(s) still in the WAL. A reader is holding it open.`);
76
+ }
77
+ if (!existsSync(opts.destDir))
78
+ mkdirSync(opts.destDir, { recursive: true });
79
+ const now = new Date();
80
+ const stamp = now.toISOString().replace(/[:.]/g, '-');
81
+ const name = opts.nameFor?.(now) ?? `backup-${stamp}.sqlite`;
82
+ const dest = join(opts.destDir, name);
83
+ const bytes = snapshotSqlite(opts.sourcePath, dest);
84
+ return {
85
+ kind: 'file',
86
+ ref: dest,
87
+ createdAt: now.toISOString(),
88
+ bytes,
89
+ restoreCommand: `cp '${dest}' '${opts.sourcePath}' # with the app stopped`,
90
+ exportCommand: null,
91
+ };
92
+ },
93
+ };
94
+ }
95
+ /**
96
+ * The D1 implementation: record the coordinate, because retention is automatic.
97
+ *
98
+ * ๐Ÿ”ด It reports `needsPeriodicCapture: false`, and a scheduler must honour that rather
99
+ * than calling `capture()` on a timer โ€” a nightly no-op job that logs "backup complete"
100
+ * is worse than no job, because it reads as evidence.
101
+ */
102
+ export function createTimeTravelBackup(opts) {
103
+ const clock = opts.now ?? (() => new Date());
104
+ const exportPath = opts.exportPath ?? `./${opts.databaseName}-export.sql`;
105
+ return {
106
+ needsPeriodicCapture: false,
107
+ async capture() {
108
+ const at = clock().toISOString();
109
+ return {
110
+ kind: 'time-travel',
111
+ ref: at,
112
+ createdAt: at,
113
+ // Cloudflare does not report a size for a restore point, and a fabricated
114
+ // number would read as a measurement.
115
+ bytes: null,
116
+ restoreCommand: `wrangler d1 time-travel restore ${opts.databaseName} --timestamp=${at}`,
117
+ exportCommand: `wrangler d1 export ${opts.databaseName} --remote --output=${exportPath}`,
118
+ };
119
+ },
120
+ };
121
+ }
122
+ /**
123
+ * Pick the backup implementation from the driver flavor, so an app's job definition reads
124
+ * the same on both sides.
125
+ */
126
+ export function backupFor(flavor, local, remote) {
127
+ return flavor === 'local' ? createLocalBackup(local()) : createTimeTravelBackup(remote());
128
+ }
@@ -0,0 +1,41 @@
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
+ import type { Database } from 'bun:sqlite';
32
+ import type { D1BindingLike } from './remote';
33
+ /**
34
+ * Wrap a real `bun:sqlite` database in D1's API and D1's restrictions.
35
+ *
36
+ * ```ts
37
+ * const sqlite = new Database(':memory:');
38
+ * const db = createRemoteD1(createFakeD1Binding(sqlite)); // behaves like the real thing
39
+ * ```
40
+ */
41
+ export declare function createFakeD1Binding(db: Database): D1BindingLike;
@@ -0,0 +1,185 @@
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
+ /** The bind types a real D1 binding accepts. Everything else throws. */
32
+ function assertD1Bindable(value, index) {
33
+ if (value === null)
34
+ return;
35
+ const t = typeof value;
36
+ if (t === 'string' || t === 'number') {
37
+ if (t === 'number' && !Number.isFinite(value)) {
38
+ throw new TypeError(`D1_TYPE_ERROR: value ${String(value)} at position ${index + 1} is not a finite number`);
39
+ }
40
+ return;
41
+ }
42
+ if (value instanceof ArrayBuffer || value instanceof Uint8Array)
43
+ return;
44
+ throw new TypeError(`D1_TYPE_ERROR: Type '${t === 'undefined' ? 'undefined' : (value?.constructor?.name ?? t)}' ` +
45
+ `not supported for value at position ${index + 1}`);
46
+ }
47
+ /** D1 returns BLOB columns as plain arrays of byte values, because the wire is JSON. */
48
+ function toWireValue(value) {
49
+ if (value instanceof Uint8Array)
50
+ return Array.from(value);
51
+ if (typeof value === 'bigint')
52
+ return Number(value);
53
+ return value ?? null;
54
+ }
55
+ const toWireRow = (row) => {
56
+ const out = {};
57
+ for (const k of Object.keys(row))
58
+ out[k] = toWireValue(row[k]);
59
+ return out;
60
+ };
61
+ const REFUSED_SQL = /^\s*(begin|commit|rollback|savepoint|release|vacuum|attach|detach|pragma\s+journal_mode)\b/i;
62
+ function assertRunnableOnD1(sql) {
63
+ if (REFUSED_SQL.test(sql)) {
64
+ throw new Error(`D1_ERROR: not authorized โ€” D1 does not permit '${sql.trim().split(/\s+/)[0]}'. ` +
65
+ 'Use batch() for atomicity; D1 has no interactive transaction and no WAL to configure.');
66
+ }
67
+ }
68
+ class FakeD1Statement {
69
+ db;
70
+ sql;
71
+ params;
72
+ constructor(db,
73
+ /** Public so `batch()` can re-run the statement inside its transaction. */
74
+ sql, params = []) {
75
+ this.db = db;
76
+ this.sql = sql;
77
+ this.params = params;
78
+ }
79
+ bind(...values) {
80
+ values.forEach((v, i) => assertD1Bindable(v, i));
81
+ // A real binding stores what it was given; the ArrayBufferโ†’Uint8Array conversion
82
+ // here is `bun:sqlite`'s requirement, not D1's, and happens below the emulated edge.
83
+ const stored = values.map((v) => (v instanceof ArrayBuffer ? new Uint8Array(v) : v));
84
+ return new FakeD1Statement(this.db, this.sql, stored);
85
+ }
86
+ meta(started, changes = 0, lastRowId = 0) {
87
+ return {
88
+ duration: performance.now() - started,
89
+ changes,
90
+ last_row_id: lastRowId,
91
+ // D1 reports these; the numbers here are not meaningful, but their PRESENCE is
92
+ // what the driver's `meta` mapping has to cope with.
93
+ rows_read: 0,
94
+ rows_written: changes,
95
+ served_by: 'fake-d1',
96
+ };
97
+ }
98
+ async first(column) {
99
+ assertRunnableOnD1(this.sql);
100
+ const row = this.db.query(this.sql).get(...this.params);
101
+ if (!row)
102
+ return null;
103
+ const wire = toWireRow(row);
104
+ if (column === undefined)
105
+ return wire;
106
+ if (!(column in wire))
107
+ throw new Error(`D1_ERROR: no such column: ${column}`);
108
+ return wire[column];
109
+ }
110
+ async all() {
111
+ assertRunnableOnD1(this.sql);
112
+ const started = performance.now();
113
+ const rows = this.db.query(this.sql).all(...this.params);
114
+ return { results: rows.map(toWireRow), success: true, meta: this.meta(started) };
115
+ }
116
+ async run() {
117
+ assertRunnableOnD1(this.sql);
118
+ const started = performance.now();
119
+ const res = this.db.query(this.sql).run(...this.params);
120
+ return {
121
+ results: [],
122
+ success: true,
123
+ meta: this.meta(started, res.changes, Number(res.lastInsertRowid)),
124
+ };
125
+ }
126
+ async raw() {
127
+ assertRunnableOnD1(this.sql);
128
+ const rows = this.db.query(this.sql).values(...this.params);
129
+ return rows.map((r) => r.map(toWireValue));
130
+ }
131
+ }
132
+ /**
133
+ * Wrap a real `bun:sqlite` database in D1's API and D1's restrictions.
134
+ *
135
+ * ```ts
136
+ * const sqlite = new Database(':memory:');
137
+ * const db = createRemoteD1(createFakeD1Binding(sqlite)); // behaves like the real thing
138
+ * ```
139
+ */
140
+ export function createFakeD1Binding(db) {
141
+ return {
142
+ prepare(sql) {
143
+ return new FakeD1Statement(db, sql);
144
+ },
145
+ async batch(statements) {
146
+ // D1's batch is atomic โ€” it is the only atomicity D1 offers. A real `bun:sqlite`
147
+ // transaction is the faithful local equivalent.
148
+ const results = [];
149
+ const run = db.transaction((stmts) => {
150
+ for (const s of stmts) {
151
+ const started = performance.now();
152
+ const inner = s;
153
+ assertRunnableOnD1(inner.sql);
154
+ const rows = db.query(inner.sql).all(...inner.params);
155
+ results.push({
156
+ results: rows.map(toWireRow),
157
+ success: true,
158
+ meta: {
159
+ duration: performance.now() - started,
160
+ changes: 0,
161
+ last_row_id: 0,
162
+ rows_read: rows.length,
163
+ rows_written: 0,
164
+ served_by: 'fake-d1',
165
+ },
166
+ });
167
+ }
168
+ });
169
+ run(statements);
170
+ return results;
171
+ },
172
+ async exec(sql) {
173
+ const started = performance.now();
174
+ const statements = sql
175
+ .split(';')
176
+ .map((s) => s.trim())
177
+ .filter((s) => s.length > 0);
178
+ for (const s of statements)
179
+ assertRunnableOnD1(s);
180
+ for (const s of statements)
181
+ db.exec(s);
182
+ return { count: statements.length, duration: performance.now() - started };
183
+ },
184
+ };
185
+ }
@@ -0,0 +1,24 @@
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
+ export { backupFor, type BackupPoint, checkpointWal, createLocalBackup, createTimeTravelBackup, type DatabaseBackup, type LocalBackupOpts, type TimeTravelOpts, } from './backup';
18
+ export { createD1Kysely, D1LikeDialect, type D1PlumbingDb } from './kysely';
19
+ export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits';
20
+ export { createLocalD1, refuseInteractiveTransaction } from './local';
21
+ export { createRemoteD1, type D1BindingLike, type D1BindingMeta, type D1BindingResult, type D1BindingStatement, } from './remote';
22
+ export { classifySchedule, cronFieldCount, jobsForCron, type PortableJob, type PortableJobContext, type ScheduleVerdict, toCronTriggers, toFiveField, type TriggerShape, type WorkersPrimitive, } from './scheduling';
23
+ export { D1BindError, type D1LikeBindable, type D1LikeDatabase, type D1LikeMeta, type D1LikeResult, type D1LikeRow, type D1LikeStatement, type D1LikeValue, D1LimitError, D1UnsupportedError, } from './types';
24
+ export { makeMeta, normalizeBind, normalizeBinds, normalizeRow, normalizeRows, normalizeValue } from './values';
@@ -0,0 +1,24 @@
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
+ export { backupFor, checkpointWal, createLocalBackup, createTimeTravelBackup, } from './backup';
18
+ export { createD1Kysely, D1LikeDialect } from './kysely';
19
+ export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits';
20
+ export { createLocalD1, refuseInteractiveTransaction } from './local';
21
+ export { createRemoteD1, } from './remote';
22
+ export { classifySchedule, cronFieldCount, jobsForCron, toCronTriggers, toFiveField, } from './scheduling';
23
+ export { D1BindError, D1LimitError, D1UnsupportedError, } from './types';
24
+ export { makeMeta, normalizeBind, normalizeBinds, normalizeRow, normalizeRows, normalizeValue } from './values';
@@ -0,0 +1,56 @@
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
+ import { type DatabaseIntrospector, type Dialect, type DialectAdapter, type Driver, Kysely, type QueryCompiler } from 'kysely';
30
+ import type { PlumbingSchema } from '../db/plumbingTypes';
31
+ import { type D1LikeDatabase } from './types';
32
+ /** The dialect itself โ€” SQLite's adapter, compiler and introspector over our driver. */
33
+ export declare class D1LikeDialect implements Dialect {
34
+ private readonly db;
35
+ constructor(db: D1LikeDatabase);
36
+ createAdapter(): DialectAdapter;
37
+ createDriver(): Driver;
38
+ createQueryCompiler(): QueryCompiler;
39
+ createIntrospector(db: Kysely<unknown>): DatabaseIntrospector;
40
+ }
41
+ /**
42
+ * A Kysely query builder over either driver.
43
+ *
44
+ * Defaults to {@link PlumbingSchema} โ€” sessions, config, logs, metrics and jobs โ€” which is
45
+ * the boundary `../db/kysely.ts` drew and this keeps: business / `data JSON` tables stay
46
+ * on the raw statement API, so the Kysely schema never grows a row per feature table.
47
+ * Apps with their own plumbing pass their own schema.
48
+ *
49
+ * ```ts
50
+ * const db = createLocalD1(sqlite); // or createRemoteD1(env.DB)
51
+ * const ky = createD1Kysely(db);
52
+ * await ky.selectFrom('auth_sessions').selectAll().execute(); // identical on both
53
+ * ```
54
+ */
55
+ export declare function createD1Kysely<Schema = PlumbingSchema>(db: D1LikeDatabase): Kysely<Schema>;
56
+ export type D1PlumbingDb = Kysely<PlumbingSchema>;
@@ -0,0 +1,138 @@
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
+ import { Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, } from 'kysely';
30
+ import { refuseInteractiveTransaction } from './local';
31
+ import { D1UnsupportedError } from './types';
32
+ /**
33
+ * Kysely compiles to a parameter list that may contain values our seam refuses
34
+ * (`undefined` from an optional column, a `Date` from a careless caller). Passing them
35
+ * through `bind()` is what turns those into a named error at the call site instead of a
36
+ * NULL locally and a 500 in production.
37
+ */
38
+ const bindParams = (sql, db, parameters) => {
39
+ const stmt = db.prepare(sql);
40
+ return parameters.length > 0 ? stmt.bind(...parameters) : stmt;
41
+ };
42
+ /**
43
+ * Decide whether a compiled query should be read (`.all()`) or executed (`.run()`).
44
+ *
45
+ * Kysely needs `numAffectedRows`/`insertId` from a write and rows from a read, and only
46
+ * `.run()` reports the former. A write carrying `RETURNING` is a read for this purpose โ€”
47
+ * its rows are the whole point of the statement.
48
+ */
49
+ function isRowReturning(query) {
50
+ const node = query;
51
+ if (node.kind === 'SelectQueryNode' || node.kind === 'RawNode')
52
+ return true;
53
+ return node.returning !== undefined;
54
+ }
55
+ class D1LikeConnection {
56
+ db;
57
+ constructor(db) {
58
+ this.db = db;
59
+ }
60
+ async executeQuery(compiled) {
61
+ const stmt = bindParams(compiled.sql, this.db, compiled.parameters);
62
+ if (isRowReturning(compiled.query)) {
63
+ const res = await stmt.all();
64
+ return { rows: res.results };
65
+ }
66
+ const res = await stmt.run();
67
+ return {
68
+ rows: [],
69
+ numAffectedRows: BigInt(res.meta.changes),
70
+ insertId: res.meta.last_row_id === null ? undefined : BigInt(res.meta.last_row_id),
71
+ };
72
+ }
73
+ // biome-ignore lint/correctness/useYield: it always throws; a generator body with no
74
+ // yield is the honest shape for an operation the seam does not have.
75
+ async *streamQuery() {
76
+ throw new D1UnsupportedError('streaming a result set', 'D1 returns a whole result set โ€” page with LIMIT/OFFSET, or a keyset cursor');
77
+ }
78
+ }
79
+ class D1LikeDriver {
80
+ db;
81
+ constructor(db) {
82
+ this.db = db;
83
+ }
84
+ async init() { }
85
+ async acquireConnection() {
86
+ // A D1 binding is not a connection: there is no pool, nothing to check out and
87
+ // nothing to close. The local driver shares the app's single handle for the same
88
+ // reason. So one connection object serves every query.
89
+ return new D1LikeConnection(this.db);
90
+ }
91
+ async beginTransaction() {
92
+ refuseInteractiveTransaction();
93
+ }
94
+ async commitTransaction() {
95
+ refuseInteractiveTransaction();
96
+ }
97
+ async rollbackTransaction() {
98
+ refuseInteractiveTransaction();
99
+ }
100
+ async releaseConnection() { }
101
+ async destroy() { }
102
+ }
103
+ /** The dialect itself โ€” SQLite's adapter, compiler and introspector over our driver. */
104
+ export class D1LikeDialect {
105
+ db;
106
+ constructor(db) {
107
+ this.db = db;
108
+ }
109
+ createAdapter() {
110
+ return new SqliteAdapter();
111
+ }
112
+ createDriver() {
113
+ return new D1LikeDriver(this.db);
114
+ }
115
+ createQueryCompiler() {
116
+ return new SqliteQueryCompiler();
117
+ }
118
+ createIntrospector(db) {
119
+ return new SqliteIntrospector(db);
120
+ }
121
+ }
122
+ /**
123
+ * A Kysely query builder over either driver.
124
+ *
125
+ * Defaults to {@link PlumbingSchema} โ€” sessions, config, logs, metrics and jobs โ€” which is
126
+ * the boundary `../db/kysely.ts` drew and this keeps: business / `data JSON` tables stay
127
+ * on the raw statement API, so the Kysely schema never grows a row per feature table.
128
+ * Apps with their own plumbing pass their own schema.
129
+ *
130
+ * ```ts
131
+ * const db = createLocalD1(sqlite); // or createRemoteD1(env.DB)
132
+ * const ky = createD1Kysely(db);
133
+ * await ky.selectFrom('auth_sessions').selectAll().execute(); // identical on both
134
+ * ```
135
+ */
136
+ export function createD1Kysely(db) {
137
+ return new Kysely({ dialect: new D1LikeDialect(db) });
138
+ }
@@ -0,0 +1,56 @@
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
+ import { type D1LikeBindable } from './types';
21
+ export declare const LIMITS: {
22
+ /** Bound parameters in a single statement. */
23
+ readonly boundParams: 100;
24
+ /** Queries a single Worker invocation may issue, including every `batch()` member. */
25
+ readonly queriesPerInvocation: 1000;
26
+ /** Bytes of SQL text in one statement. A generated `IN (โ€ฆ)` list hits this first. */
27
+ readonly sqlLength: 100000;
28
+ /** Bytes in any single string or BLOB value, and in any single row. */
29
+ readonly valueBytes: 2000000;
30
+ /** Columns in one table. The fleet's `data JSON` pattern stays far under it. */
31
+ readonly columnsPerTable: 100;
32
+ /** Wall-clock milliseconds one query may take. */
33
+ readonly queryDurationMs: 30000;
34
+ };
35
+ /**
36
+ * Refuse a statement that D1 would refuse โ€” checked on BOTH drivers, at `bind()` time, so
37
+ * the failure lands on the call site that built the query rather than in production.
38
+ */
39
+ export declare function assertWithinLimits(sql: string, params: readonly D1LikeBindable[]): void;
40
+ /** Refuse a `batch()` bigger than one Worker invocation may issue. */
41
+ export declare function assertBatchSize(count: number): void;
42
+ /**
43
+ * Split a list into chunks that fit under the 100-parameter cap, given how many bound
44
+ * parameters each item contributes.
45
+ *
46
+ * A bulk insert of `n` rows with `c` columns binds `n * c` parameters, so the batch size
47
+ * is `floor(100 / c)` โ€” the arithmetic nobody does correctly by eye at 3 a.m., which is
48
+ * why it is a function and not a sentence in a doc.
49
+ *
50
+ * ```ts
51
+ * for (const rows of chunkForBind(people, 4)) { // 4 columns โ†’ 25 rows a chunk
52
+ * await db.prepare(insertSql(rows.length)).bind(...rows.flatMap(toParams)).run();
53
+ * }
54
+ * ```
55
+ */
56
+ export declare function chunkForBind<T>(items: readonly T[], paramsPerItem: number): T[][];