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.
- package/dist/server/bench/assert.d.ts +61 -0
- package/dist/server/bench/assert.js +117 -0
- package/dist/server/bench/budget.d.ts +130 -0
- package/dist/server/bench/budget.js +131 -0
- package/dist/server/bench/cpuBudget.d.ts +45 -0
- package/dist/server/bench/cpuBudget.js +34 -0
- package/dist/server/bench/cpuClock.d.ts +65 -0
- package/dist/server/bench/cpuClock.js +100 -0
- package/dist/server/bench/index.d.ts +40 -0
- package/dist/server/bench/index.js +40 -0
- package/dist/server/bench/recorder.d.ts +70 -0
- package/dist/server/bench/recorder.js +95 -0
- package/dist/server/bench/runBench.d.ts +61 -0
- package/dist/server/bench/runBench.js +61 -0
- package/dist/server/d1/backup.d.ts +110 -0
- package/dist/server/d1/backup.js +128 -0
- package/dist/server/d1/fakeD1.d.ts +41 -0
- package/dist/server/d1/fakeD1.js +185 -0
- package/dist/server/d1/index.d.ts +24 -0
- package/dist/server/d1/index.js +24 -0
- package/dist/server/d1/kysely.d.ts +56 -0
- package/dist/server/d1/kysely.js +138 -0
- package/dist/server/d1/limits.d.ts +56 -0
- package/dist/server/d1/limits.js +96 -0
- package/dist/server/d1/local.d.ts +31 -0
- package/dist/server/d1/local.js +135 -0
- package/dist/server/d1/remote.d.ts +59 -0
- package/dist/server/d1/remote.js +124 -0
- package/dist/server/d1/scheduling.d.ts +113 -0
- package/dist/server/d1/scheduling.js +164 -0
- package/dist/server/d1/types.d.ts +143 -0
- package/dist/server/d1/types.js +80 -0
- package/dist/server/d1/values.d.ts +50 -0
- package/dist/server/d1/values.js +124 -0
- package/dist/server/sync/http.d.ts +20 -3
- package/dist/server/sync/http.js +20 -14
- package/dist/server/sync/index.d.ts +10 -2
- package/dist/server/sync/index.js +9 -1
- package/dist/server/sync/planner.d.ts +38 -8
- package/dist/server/sync/planner.js +32 -8
- package/dist/server/sync/signal.d.ts +161 -0
- package/dist/server/sync/signal.js +348 -0
- package/dist/server/sync/timer.d.ts +63 -19
- package/dist/server/sync/timer.js +104 -45
- package/dist/server/sync/types.d.ts +0 -2
- package/package.json +21 -3
- package/src/leafSubpathsImportNothing.spec.ts +15 -3
- package/src/noTimerDialsAPeer.spec.ts +469 -0
- package/src/server/bench/assert.ts +192 -0
- package/src/server/bench/budget.spec.ts +126 -0
- package/src/server/bench/budget.ts +207 -0
- package/src/server/bench/cpuBudget.spec.ts +302 -0
- package/src/server/bench/cpuBudget.ts +81 -0
- package/src/server/bench/cpuClock.ts +119 -0
- package/src/server/bench/index.ts +81 -0
- package/src/server/bench/recorder.ts +163 -0
- package/src/server/bench/runBench.ts +110 -0
- package/src/server/d1/backup.spec.ts +121 -0
- package/src/server/d1/backup.ts +186 -0
- package/src/server/d1/fakeD1.ts +193 -0
- package/src/server/d1/index.ts +62 -0
- package/src/server/d1/kysely.spec.ts +145 -0
- package/src/server/d1/kysely.ts +169 -0
- package/src/server/d1/limits.spec.ts +90 -0
- package/src/server/d1/limits.ts +123 -0
- package/src/server/d1/local.ts +173 -0
- package/src/server/d1/remote.ts +182 -0
- package/src/server/d1/sameShape.spec.ts +279 -0
- package/src/server/d1/scheduling.spec.ts +120 -0
- package/src/server/d1/scheduling.ts +210 -0
- package/src/server/d1/types.ts +163 -0
- package/src/server/d1/values.ts +138 -0
- package/src/server/sync/http.ts +31 -16
- package/src/server/sync/index.ts +23 -1
- package/src/server/sync/planner.spec.ts +33 -16
- package/src/server/sync/planner.ts +48 -11
- package/src/server/sync/signal.spec.ts +306 -0
- package/src/server/sync/signal.ts +422 -0
- package/src/server/sync/timer.spec.ts +97 -16
- package/src/server/sync/timer.ts +124 -47
- package/src/server/sync/types.ts +0 -2
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The REMOTE implementation of {@link D1LikeDatabase}, over a Cloudflare D1 binding.
|
|
3
|
+
*
|
|
4
|
+
* It is thin on purpose. D1's own API is the shape `./types` mirrors, so nearly all of
|
|
5
|
+
* this file is the normalization that makes a D1 answer indistinguishable from a
|
|
6
|
+
* `bun:sqlite` one — the same `normalizeBind` / `normalizeValue` the local driver calls,
|
|
7
|
+
* so "the two drivers agree" is a property of one implementation rather than a
|
|
8
|
+
* coincidence between two.
|
|
9
|
+
*
|
|
10
|
+
* 🔴 **`workerd` is not imported here, and must not be.** This package is built and tested
|
|
11
|
+
* with Bun, and `@cloudflare/workers-types` would be a dependency the other 99 % of the
|
|
12
|
+
* library does not need. The binding is described structurally by {@link D1BindingLike}
|
|
13
|
+
* below, which is the subset of Cloudflare's `D1Database` this driver actually calls — so
|
|
14
|
+
* a real binding satisfies it without a cast, and the test suite can satisfy it with a
|
|
15
|
+
* fake that has no `workerd` in sight.
|
|
16
|
+
*/
|
|
17
|
+
|
|
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
|
+
/** What D1 reports back about a statement. Its `meta` is richer than the local driver's. */
|
|
31
|
+
export interface D1BindingMeta {
|
|
32
|
+
duration?: number;
|
|
33
|
+
changes?: number;
|
|
34
|
+
last_row_id?: number;
|
|
35
|
+
rows_read?: number;
|
|
36
|
+
rows_written?: number;
|
|
37
|
+
served_by?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface D1BindingResult {
|
|
41
|
+
results?: unknown[];
|
|
42
|
+
success?: boolean;
|
|
43
|
+
meta?: D1BindingMeta;
|
|
44
|
+
error?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The subset of Cloudflare's `D1PreparedStatement` this driver calls. */
|
|
48
|
+
export interface D1BindingStatement {
|
|
49
|
+
bind(...values: unknown[]): D1BindingStatement;
|
|
50
|
+
first(column?: string): Promise<unknown>;
|
|
51
|
+
all(): Promise<D1BindingResult>;
|
|
52
|
+
run(): Promise<D1BindingResult>;
|
|
53
|
+
raw(): Promise<unknown[][]>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The subset of Cloudflare's `D1Database` this driver calls. A real binding satisfies it. */
|
|
57
|
+
export interface D1BindingLike {
|
|
58
|
+
prepare(sql: string): D1BindingStatement;
|
|
59
|
+
batch(statements: D1BindingStatement[]): Promise<D1BindingResult[]>;
|
|
60
|
+
exec(sql: string): Promise<{ count: number; duration: number }>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* D1 reports a failed statement by RESOLVING with `success: false` in some paths rather
|
|
65
|
+
* than rejecting, so a driver that only catches rejections reports a silent no-op as a
|
|
66
|
+
* successful write. Every result goes through here.
|
|
67
|
+
*/
|
|
68
|
+
function assertSucceeded(res: D1BindingResult, sql: string): void {
|
|
69
|
+
if (res.success === false || res.error) {
|
|
70
|
+
throw new Error(`D1 refused the statement: ${res.error ?? 'success: false'}\n ${sql.slice(0, 160)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const toMeta = (res: D1BindingResult) =>
|
|
75
|
+
makeMeta({
|
|
76
|
+
changes: res.meta?.changes ?? 0,
|
|
77
|
+
last_row_id: res.meta?.last_row_id ?? null,
|
|
78
|
+
duration: res.meta?.duration ?? 0,
|
|
79
|
+
rows_read: res.meta?.rows_read ?? null,
|
|
80
|
+
rows_written: res.meta?.rows_written ?? null,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
class RemoteStatement implements D1LikeStatement {
|
|
84
|
+
constructor(
|
|
85
|
+
private readonly stmt: D1BindingStatement,
|
|
86
|
+
private readonly sql: string,
|
|
87
|
+
) {}
|
|
88
|
+
|
|
89
|
+
/** The underlying binding statement — how `batch()` reaches the real objects. */
|
|
90
|
+
get binding(): D1BindingStatement {
|
|
91
|
+
return this.stmt;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
bind(...values: D1LikeBindable[]): D1LikeStatement {
|
|
95
|
+
const params = normalizeBinds(values);
|
|
96
|
+
assertWithinLimits(this.sql, params);
|
|
97
|
+
// D1 accepts ArrayBuffer for BLOBs; `normalizeBind` has already reduced every blob
|
|
98
|
+
// to a `Uint8Array`, so this is the one place that converts back out.
|
|
99
|
+
const wire = params.map((p) => (p instanceof Uint8Array ? p.buffer.slice(p.byteOffset, p.byteOffset + p.byteLength) : p));
|
|
100
|
+
return new RemoteStatement(this.stmt.bind(...wire), this.sql);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async first<T = D1LikeRow>(column?: string): Promise<T | null> {
|
|
104
|
+
const row = await this.stmt.first(column);
|
|
105
|
+
if (row === null || row === undefined) return null;
|
|
106
|
+
if (column !== undefined) return normalizeValue(row) as T;
|
|
107
|
+
return normalizeRows<T>([row as Record<string, unknown>])[0] ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async all<T = D1LikeRow>(): Promise<D1LikeResult<T>> {
|
|
111
|
+
const res = await this.stmt.all();
|
|
112
|
+
assertSucceeded(res, this.sql);
|
|
113
|
+
return {
|
|
114
|
+
results: normalizeRows<T>((res.results ?? []) as Record<string, unknown>[]),
|
|
115
|
+
success: true,
|
|
116
|
+
meta: toMeta(res),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async run(): Promise<D1LikeResult<never>> {
|
|
121
|
+
const res = await this.stmt.run();
|
|
122
|
+
assertSucceeded(res, this.sql);
|
|
123
|
+
return { results: [], success: true, meta: toMeta(res) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async raw<V = D1LikeValue>(): Promise<V[][]> {
|
|
127
|
+
const rows = await this.stmt.raw();
|
|
128
|
+
return rows.map((r) => r.map((v) => normalizeValue(v) as V));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Wrap a Cloudflare D1 binding in the async seam.
|
|
134
|
+
*
|
|
135
|
+
* On a Worker this is constructed per request from `env.DB` — a binding is not a
|
|
136
|
+
* connection and holds no state worth caching, so there is nothing to pool and nothing to
|
|
137
|
+
* close.
|
|
138
|
+
*/
|
|
139
|
+
export function createRemoteD1(binding: D1BindingLike): D1LikeDatabase {
|
|
140
|
+
return {
|
|
141
|
+
flavor: 'd1',
|
|
142
|
+
|
|
143
|
+
prepare(sql: string): D1LikeStatement {
|
|
144
|
+
return new RemoteStatement(binding.prepare(sql), sql);
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
async batch<T = D1LikeRow>(statements: D1LikeStatement[]): Promise<D1LikeResult<T>[]> {
|
|
148
|
+
assertBatchSize(statements.length);
|
|
149
|
+
const bindings = statements.map((s) => {
|
|
150
|
+
const b = (s as Partial<RemoteStatement>).binding;
|
|
151
|
+
if (!b) {
|
|
152
|
+
throw new TypeError(
|
|
153
|
+
'batch() accepts only statements from this same D1 database — ' +
|
|
154
|
+
'a local statement cannot run inside a D1 batch.',
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return b;
|
|
158
|
+
});
|
|
159
|
+
const results = await binding.batch(bindings);
|
|
160
|
+
return results.map((res, i) => {
|
|
161
|
+
assertSucceeded(res, `batch statement ${i + 1}`);
|
|
162
|
+
return {
|
|
163
|
+
results: normalizeRows<T>((res.results ?? []) as Record<string, unknown>[]),
|
|
164
|
+
success: true as const,
|
|
165
|
+
meta: toMeta(res),
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
exec(sql: string): Promise<{ count: number; duration: number }> {
|
|
171
|
+
return binding.exec(sql);
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Re-exported so both drivers refuse an interactive transaction with one message. */
|
|
177
|
+
export const refuseInteractiveTransaction = (): never => {
|
|
178
|
+
throw new D1UnsupportedError(
|
|
179
|
+
'an interactive transaction (BEGIN/COMMIT)',
|
|
180
|
+
'collect the statements and pass them to `db.batch([...])`, which is atomic on both drivers',
|
|
181
|
+
);
|
|
182
|
+
};
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🔴 The load-bearing test of the whole seam: the local and remote drivers return
|
|
3
|
+
* IDENTICAL shapes for the same query.
|
|
4
|
+
*
|
|
5
|
+
* Every case runs the same SQL through both drivers and compares the results to each
|
|
6
|
+
* other — not to a hand-written expectation. An expectation written by the same person who
|
|
7
|
+
* wrote the driver encodes their belief; comparing the two drivers encodes the property
|
|
8
|
+
* the fleet actually needs, which is that a ported call site cannot tell which side it is
|
|
9
|
+
* on.
|
|
10
|
+
*
|
|
11
|
+
* The remote driver runs against `createFakeD1Binding`, which deliberately reproduces the
|
|
12
|
+
* four places SQLite drivers disagree (BLOB wire shape, bind strictness, result wrapping,
|
|
13
|
+
* no interactive transaction). See the header of `./fakeD1` — if that fake ever drifts
|
|
14
|
+
* toward mirroring our driver, this file stops proving anything.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Database } from 'bun:sqlite';
|
|
18
|
+
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
19
|
+
import { createFakeD1Binding } from './fakeD1';
|
|
20
|
+
import { createLocalD1 } from './local';
|
|
21
|
+
import { createRemoteD1 } from './remote';
|
|
22
|
+
import { D1BindError, type D1LikeBindable, type D1LikeDatabase } from './types';
|
|
23
|
+
|
|
24
|
+
const DDL = `CREATE TABLE t (
|
|
25
|
+
id INTEGER PRIMARY KEY,
|
|
26
|
+
int_col INTEGER,
|
|
27
|
+
real_col REAL,
|
|
28
|
+
text_col TEXT,
|
|
29
|
+
blob_col BLOB,
|
|
30
|
+
bool_col INTEGER,
|
|
31
|
+
null_col TEXT
|
|
32
|
+
)`;
|
|
33
|
+
|
|
34
|
+
/** Two databases with identical contents, one behind each driver. */
|
|
35
|
+
function pair(): { local: D1LikeDatabase; remote: D1LikeDatabase; seed: (sql: string) => void } {
|
|
36
|
+
const a = new Database(':memory:');
|
|
37
|
+
const b = new Database(':memory:');
|
|
38
|
+
a.run(DDL);
|
|
39
|
+
b.run(DDL);
|
|
40
|
+
return {
|
|
41
|
+
local: createLocalD1(a),
|
|
42
|
+
remote: createRemoteD1(createFakeD1Binding(b)),
|
|
43
|
+
seed: (sql: string) => {
|
|
44
|
+
a.run(sql);
|
|
45
|
+
b.run(sql);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Run the same statement on both drivers and return both answers. */
|
|
51
|
+
async function both<T>(
|
|
52
|
+
p: ReturnType<typeof pair>,
|
|
53
|
+
fn: (db: D1LikeDatabase) => Promise<T>,
|
|
54
|
+
): Promise<{ local: T; remote: T }> {
|
|
55
|
+
return { local: await fn(p.local), remote: await fn(p.remote) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('local and D1 drivers return identical shapes', () => {
|
|
59
|
+
let p: ReturnType<typeof pair>;
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
p = pair();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ── the four places SQLite drivers disagree ──────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
test('NULL — an absent column is null on both, not undefined and not omitted', async () => {
|
|
67
|
+
p.seed(`INSERT INTO t (id, int_col, null_col) VALUES (1, 7, NULL)`);
|
|
68
|
+
const { local, remote } = await both(p, (db) => db.prepare('SELECT * FROM t').all());
|
|
69
|
+
|
|
70
|
+
expect(local.results).toEqual(remote.results);
|
|
71
|
+
expect(local.results[0]).toHaveProperty('null_col', null);
|
|
72
|
+
// The column must be PRESENT and null — a driver that drops null columns changes
|
|
73
|
+
// `'null_col' in row` and every `??` downstream of it.
|
|
74
|
+
expect(Object.keys(local.results[0] as object)).toEqual(Object.keys(remote.results[0] as object));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('BLOB — Uint8Array on both, where D1 natively hands back a number[]', async () => {
|
|
78
|
+
p.seed(`INSERT INTO t (id, blob_col) VALUES (1, X'00FF107F')`);
|
|
79
|
+
const { local, remote } = await both(p, (db) => db.prepare('SELECT blob_col FROM t').all());
|
|
80
|
+
|
|
81
|
+
const lv = (local.results[0] as { blob_col: unknown }).blob_col;
|
|
82
|
+
const rv = (remote.results[0] as { blob_col: unknown }).blob_col;
|
|
83
|
+
|
|
84
|
+
expect(lv).toBeInstanceOf(Uint8Array);
|
|
85
|
+
expect(rv).toBeInstanceOf(Uint8Array);
|
|
86
|
+
expect(Array.from(rv as Uint8Array)).toEqual([0, 255, 16, 127]);
|
|
87
|
+
expect(lv).toEqual(rv);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('BLOB — an EMPTY blob stays an empty Uint8Array, not null', async () => {
|
|
91
|
+
// The case a `value ?? null` normalizer gets wrong: an empty array is falsy-adjacent
|
|
92
|
+
// and an empty Uint8Array is not null.
|
|
93
|
+
p.seed(`INSERT INTO t (id, blob_col) VALUES (1, X'')`);
|
|
94
|
+
const { local, remote } = await both(p, (db) => db.prepare('SELECT blob_col FROM t').all());
|
|
95
|
+
const lv = (local.results[0] as { blob_col: unknown }).blob_col;
|
|
96
|
+
expect(lv).toBeInstanceOf(Uint8Array);
|
|
97
|
+
expect((lv as Uint8Array).length).toBe(0);
|
|
98
|
+
expect(lv).toEqual((remote.results[0] as { blob_col: unknown }).blob_col);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('integer vs float — REAL 1.0 and INTEGER 1 agree across drivers', async () => {
|
|
102
|
+
p.seed(`INSERT INTO t (id, int_col, real_col) VALUES (1, 1, 1.0)`);
|
|
103
|
+
p.seed(`INSERT INTO t (id, int_col, real_col) VALUES (2, 2, 2.5)`);
|
|
104
|
+
const { local, remote } = await both(p, (db) =>
|
|
105
|
+
db.prepare('SELECT int_col, real_col FROM t ORDER BY id').all(),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(local.results).toEqual(remote.results);
|
|
109
|
+
// SQLite stores 1.0 in a REAL column and both drivers surface it as the number 1.
|
|
110
|
+
// Asserted so a future driver change that started returning "1.0" is caught here.
|
|
111
|
+
expect(local.results[0]).toEqual({ int_col: 1, real_col: 1 });
|
|
112
|
+
expect(local.results[1]).toEqual({ int_col: 2, real_col: 2.5 });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('boolean — bound as true/false, stored 1/0, read back identically on both', async () => {
|
|
116
|
+
const insert = (db: D1LikeDatabase, id: number, v: boolean) =>
|
|
117
|
+
db.prepare('INSERT INTO t (id, bool_col) VALUES (?, ?)').bind(id, v).run();
|
|
118
|
+
|
|
119
|
+
await insert(p.local, 1, true);
|
|
120
|
+
await insert(p.remote, 1, true);
|
|
121
|
+
await insert(p.local, 2, false);
|
|
122
|
+
await insert(p.remote, 2, false);
|
|
123
|
+
|
|
124
|
+
const { local, remote } = await both(p, (db) => db.prepare('SELECT id, bool_col FROM t ORDER BY id').all());
|
|
125
|
+
expect(local.results).toEqual(remote.results);
|
|
126
|
+
expect(local.results).toEqual([
|
|
127
|
+
{ id: 1, bool_col: 1 },
|
|
128
|
+
{ id: 2, bool_col: 0 },
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ── the strictness that must match, or it is green here and red in production ─────
|
|
133
|
+
|
|
134
|
+
test('undefined bind is REFUSED by both, with the same error', async () => {
|
|
135
|
+
const attempt = async (db: D1LikeDatabase) => {
|
|
136
|
+
try {
|
|
137
|
+
await db
|
|
138
|
+
.prepare('INSERT INTO t (id, text_col) VALUES (?, ?)')
|
|
139
|
+
.bind(1, undefined as unknown as D1LikeBindable)
|
|
140
|
+
.run();
|
|
141
|
+
return 'no error';
|
|
142
|
+
} catch (e) {
|
|
143
|
+
return `${(e as Error).name}: ${(e as Error).message}`;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const local = await attempt(p.local);
|
|
147
|
+
const remote = await attempt(p.remote);
|
|
148
|
+
|
|
149
|
+
// 🔴 `bun:sqlite` alone would have bound NULL here and said nothing, while D1 throws.
|
|
150
|
+
// That divergence is the defect; identical refusal is the fix.
|
|
151
|
+
expect(local).toBe(remote);
|
|
152
|
+
expect(local).toContain('D1BindError');
|
|
153
|
+
expect(local).toContain('pass null explicitly');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('a Date bind is refused by both, naming the fix', async () => {
|
|
157
|
+
for (const db of [p.local, p.remote]) {
|
|
158
|
+
expect(() =>
|
|
159
|
+
db.prepare('INSERT INTO t (id, text_col) VALUES (?, ?)').bind(1, new Date() as unknown as D1LikeBindable),
|
|
160
|
+
).toThrow(D1BindError);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('an out-of-range bigint is refused rather than silently rounded', async () => {
|
|
165
|
+
// Measured: `bun:sqlite` returns 9007199254740992 for a stored 9007199254740993.
|
|
166
|
+
for (const db of [p.local, p.remote]) {
|
|
167
|
+
expect(() => db.prepare('INSERT INTO t (id, int_col) VALUES (1, ?)').bind(9007199254740993n)).toThrow(
|
|
168
|
+
D1BindError,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
// …and one inside the range is accepted, as a number, on both.
|
|
172
|
+
const { local, remote } = await both(p, async (db) => {
|
|
173
|
+
await db.prepare('INSERT INTO t (id, int_col) VALUES (1, ?)').bind(42n).run();
|
|
174
|
+
return db.prepare('SELECT int_col FROM t').first();
|
|
175
|
+
});
|
|
176
|
+
expect(local).toEqual(remote);
|
|
177
|
+
expect(local).toEqual({ int_col: 42 });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ── result and meta shapes ────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
test('first() on no rows is null on both, not undefined', async () => {
|
|
183
|
+
const { local, remote } = await both(p, (db) => db.prepare('SELECT * FROM t WHERE id = 999').first());
|
|
184
|
+
expect(local).toBeNull();
|
|
185
|
+
expect(remote).toBeNull();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('all() on no rows is an empty results array on both', async () => {
|
|
189
|
+
const { local, remote } = await both(p, (db) => db.prepare('SELECT * FROM t WHERE id = 999').all());
|
|
190
|
+
expect(local.results).toEqual([]);
|
|
191
|
+
expect(remote.results).toEqual([]);
|
|
192
|
+
expect(local.success).toBe(remote.success);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('first(column) returns the bare value on both', async () => {
|
|
196
|
+
p.seed(`INSERT INTO t (id, text_col) VALUES (1, 'hello')`);
|
|
197
|
+
const { local, remote } = await both(p, (db) => db.prepare('SELECT text_col FROM t').first('text_col'));
|
|
198
|
+
expect(local).toBe('hello');
|
|
199
|
+
expect(remote).toBe('hello');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('run() reports the same changes and last_row_id on both', async () => {
|
|
203
|
+
const { local, remote } = await both(p, (db) =>
|
|
204
|
+
db.prepare('INSERT INTO t (text_col) VALUES (?)').bind('x').run(),
|
|
205
|
+
);
|
|
206
|
+
expect(local.meta.changes).toBe(remote.meta.changes);
|
|
207
|
+
expect(local.meta.changes).toBe(1);
|
|
208
|
+
expect(local.meta.last_row_id).toBe(remote.meta.last_row_id);
|
|
209
|
+
expect(local.meta.last_row_id).toBe(1);
|
|
210
|
+
expect(local.results).toEqual(remote.results);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('meta carries the same KEYS on both, so a caller can read it blind', async () => {
|
|
214
|
+
const { local, remote } = await both(p, (db) => db.prepare('SELECT * FROM t').all());
|
|
215
|
+
expect(Object.keys(local.meta).sort()).toEqual(Object.keys(remote.meta).sort());
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('a read reports last_row_id null on both, rather than a misleading 0', async () => {
|
|
219
|
+
p.seed(`INSERT INTO t (id, int_col) VALUES (1, 1)`);
|
|
220
|
+
const { local, remote } = await both(p, (db) => db.prepare('SELECT * FROM t').all());
|
|
221
|
+
expect(local.meta.last_row_id).toBeNull();
|
|
222
|
+
expect(remote.meta.last_row_id).toBeNull();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('raw() returns positional arrays that match across drivers', async () => {
|
|
226
|
+
p.seed(`INSERT INTO t (id, text_col, blob_col) VALUES (1, 'a', X'0102')`);
|
|
227
|
+
const { local, remote } = await both(p, (db) => db.prepare('SELECT id, text_col, blob_col FROM t').raw());
|
|
228
|
+
expect(local).toEqual(remote);
|
|
229
|
+
expect(local[0]?.[0]).toBe(1);
|
|
230
|
+
expect(local[0]?.[1]).toBe('a');
|
|
231
|
+
expect(local[0]?.[2]).toBeInstanceOf(Uint8Array);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('a round-tripped blob bind survives identically on both', async () => {
|
|
235
|
+
const bytes = new Uint8Array([0, 1, 254, 255]);
|
|
236
|
+
const { local, remote } = await both(p, async (db) => {
|
|
237
|
+
await db.prepare('INSERT INTO t (id, blob_col) VALUES (1, ?)').bind(bytes).run();
|
|
238
|
+
return db.prepare('SELECT blob_col FROM t').first<{ blob_col: Uint8Array }>();
|
|
239
|
+
});
|
|
240
|
+
expect(local?.blob_col).toEqual(remote?.blob_col as Uint8Array);
|
|
241
|
+
expect(Array.from(local?.blob_col as Uint8Array)).toEqual([0, 1, 254, 255]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ── batch, which is the only transaction either side has ──────────────────────────
|
|
245
|
+
|
|
246
|
+
test('batch() runs every statement and returns one result each, on both', async () => {
|
|
247
|
+
const { local, remote } = await both(p, async (db) => {
|
|
248
|
+
const res = await db.batch([
|
|
249
|
+
db.prepare("INSERT INTO t (id, text_col) VALUES (1, 'a')"),
|
|
250
|
+
db.prepare("INSERT INTO t (id, text_col) VALUES (2, 'b')"),
|
|
251
|
+
]);
|
|
252
|
+
const rows = await db.prepare('SELECT id, text_col FROM t ORDER BY id').all();
|
|
253
|
+
return { count: res.length, rows: rows.results };
|
|
254
|
+
});
|
|
255
|
+
expect(local).toEqual(remote);
|
|
256
|
+
expect(local.count).toBe(2);
|
|
257
|
+
expect(local.rows).toEqual([
|
|
258
|
+
{ id: 1, text_col: 'a' },
|
|
259
|
+
{ id: 2, text_col: 'b' },
|
|
260
|
+
]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('batch() is atomic on both — a failing member rolls the whole batch back', async () => {
|
|
264
|
+
const attempt = async (db: D1LikeDatabase) => {
|
|
265
|
+
try {
|
|
266
|
+
await db.batch([
|
|
267
|
+
db.prepare("INSERT INTO t (id, text_col) VALUES (1, 'a')"),
|
|
268
|
+
// Duplicate primary key — fails, and must take the first insert with it.
|
|
269
|
+
db.prepare("INSERT INTO t (id, text_col) VALUES (1, 'b')"),
|
|
270
|
+
]);
|
|
271
|
+
} catch {
|
|
272
|
+
/* expected */
|
|
273
|
+
}
|
|
274
|
+
return (await db.prepare('SELECT COUNT(*) AS n FROM t').first<{ n: number }>())?.n;
|
|
275
|
+
};
|
|
276
|
+
expect(await attempt(p.local)).toBe(0);
|
|
277
|
+
expect(await attempt(p.remote)).toBe(0);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
classifySchedule,
|
|
4
|
+
cronFieldCount,
|
|
5
|
+
jobsForCron,
|
|
6
|
+
type PortableJob,
|
|
7
|
+
toCronTriggers,
|
|
8
|
+
toFiveField,
|
|
9
|
+
} from './scheduling';
|
|
10
|
+
|
|
11
|
+
const job = (name: string, shape: PortableJob['shape']): PortableJob => ({
|
|
12
|
+
name,
|
|
13
|
+
shape,
|
|
14
|
+
run: async () => {},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('classifySchedule', () => {
|
|
18
|
+
test('a nightly backup is a Cron Trigger and costs nothing', () => {
|
|
19
|
+
const v = classifySchedule({ kind: 'periodic', cron: '0 3 * * *' });
|
|
20
|
+
expect(v.primitive).toBe('cron-trigger');
|
|
21
|
+
expect(v.cron).toBe('0 3 * * *');
|
|
22
|
+
expect(v.estimatedMonthlyUsd).toBe(0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('a 6-field expression with a fixed seconds column drops it losslessly', () => {
|
|
26
|
+
// `0 3 * * * *` and `3 * * *` fire at the same wall-clock moments.
|
|
27
|
+
const v = classifySchedule({ kind: 'periodic', cron: '0 3 * * * *' });
|
|
28
|
+
expect(v.primitive).toBe('cron-trigger');
|
|
29
|
+
expect(v.cron).toBe('3 * * * *');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('🔴 a once-per-second job is UNPORTABLE, not silently rounded to a minute', () => {
|
|
33
|
+
// This repo already declares three jobs as `* * * * * *`. Rounding them up would
|
|
34
|
+
// make them 60× less frequent in production and nowhere else.
|
|
35
|
+
const v = classifySchedule({ kind: 'periodic', cron: '* * * * * *' });
|
|
36
|
+
expect(v.primitive).toBe('unportable');
|
|
37
|
+
expect(v.cron).toBeNull();
|
|
38
|
+
expect(v.reason).toContain('one-minute floor');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('a sub-minute STEP is unportable too', () => {
|
|
42
|
+
const v = classifySchedule({ kind: 'periodic', cron: '*/10 * * * * *' });
|
|
43
|
+
expect(v.primitive).toBe('unportable');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('work fanned out from a request is a Queue, priced at two operations a message', () => {
|
|
47
|
+
// 1M operations are included; a delivered message costs one write and one read, so
|
|
48
|
+
// the free tier covers 500k messages a month, not 1M. Pricing it as one operation
|
|
49
|
+
// is the mistake that makes Queues look half price.
|
|
50
|
+
const free = classifySchedule({ kind: 'fanned-out-from-request', expectedPerDay: 16_000 });
|
|
51
|
+
expect(free.primitive).toBe('queue');
|
|
52
|
+
expect(free.estimatedMonthlyUsd).toBe(0); // 480k messages → 960k ops, still included
|
|
53
|
+
|
|
54
|
+
const paid = classifySchedule({ kind: 'fanned-out-from-request', expectedPerDay: 100_000 });
|
|
55
|
+
// 3M messages → 6M ops → 5M billable → $2.00
|
|
56
|
+
expect(paid.estimatedMonthlyUsd).toBeCloseTo(2.0, 4);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('per-entity scheduling is a Durable Object Alarm', () => {
|
|
60
|
+
const v = classifySchedule({ kind: 'per-entity' });
|
|
61
|
+
expect(v.primitive).toBe('durable-object-alarm');
|
|
62
|
+
expect(v.reason).toContain('scan every row');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('cron helpers', () => {
|
|
67
|
+
test('cronFieldCount tolerates ragged whitespace', () => {
|
|
68
|
+
expect(cronFieldCount(' 0 3 * * * ')).toBe(5);
|
|
69
|
+
expect(cronFieldCount('0 0 3 * * *')).toBe(6);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('toFiveField leaves a 5-field expression alone', () => {
|
|
73
|
+
expect(toFiveField('0 3 * * *')).toEqual({ cron: '0 3 * * *', lossless: true });
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('toCronTriggers', () => {
|
|
78
|
+
test('renders the wrangler triggers block, de-duplicating shared expressions', () => {
|
|
79
|
+
const { crons, queues } = toCronTriggers([
|
|
80
|
+
job('backup', { kind: 'periodic', cron: '0 3 * * *' }),
|
|
81
|
+
job('metrics-flush', { kind: 'periodic', cron: '0 3 * * *' }),
|
|
82
|
+
job('thumbnails', { kind: 'fanned-out-from-request', expectedPerDay: 10 }),
|
|
83
|
+
]);
|
|
84
|
+
expect(crons).toEqual(['0 3 * * *']);
|
|
85
|
+
expect(queues).toEqual(['thumbnails']);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('🔴 THROWS on an unportable job rather than quietly omitting it', () => {
|
|
89
|
+
// A generated config that drops a job is a job that stops running in production
|
|
90
|
+
// while every local test still passes.
|
|
91
|
+
expect(() =>
|
|
92
|
+
toCronTriggers([job('poller', { kind: 'periodic', cron: '* * * * * *' })]),
|
|
93
|
+
).toThrow(/cannot be expressed/);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('jobsForCron', () => {
|
|
98
|
+
test('🔴 both jobs sharing an expression are returned, not just the first', () => {
|
|
99
|
+
// Cloudflare dispatches by EXPRESSION, not by job name: two jobs on `0 3 * * *`
|
|
100
|
+
// arrive as ONE event. A handler that ran the first match would silently never run
|
|
101
|
+
// the second.
|
|
102
|
+
const jobs = [
|
|
103
|
+
job('backup', { kind: 'periodic', cron: '0 3 * * *' }),
|
|
104
|
+
job('metrics-flush', { kind: 'periodic', cron: '0 3 * * *' }),
|
|
105
|
+
job('hourly', { kind: 'periodic', cron: '0 * * * *' }),
|
|
106
|
+
];
|
|
107
|
+
expect(jobsForCron(jobs, '0 3 * * *').map((j) => j.name)).toEqual(['backup', 'metrics-flush']);
|
|
108
|
+
expect(jobsForCron(jobs, '0 * * * *').map((j) => j.name)).toEqual(['hourly']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('a 6-field declaration is matched by its rendered 5-field expression', () => {
|
|
112
|
+
const jobs = [job('nightly', { kind: 'periodic', cron: '0 3 * * * *' })];
|
|
113
|
+
expect(jobsForCron(jobs, '3 * * * *').map((j) => j.name)).toEqual(['nightly']);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('a queue job is never dispatched by a cron event', () => {
|
|
117
|
+
const jobs = [job('thumbs', { kind: 'fanned-out-from-request' })];
|
|
118
|
+
expect(jobsForCron(jobs, '0 3 * * *')).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
});
|