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,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `plainjob` → the Workers answer, and the choice is measured rather than guessed.
|
|
3
|
+
*
|
|
4
|
+
* ## What `plainjob` actually is, and why a Worker cannot have it
|
|
5
|
+
*
|
|
6
|
+
* An in-process SQLite job queue that POLLS a table on a timer. A Worker has no background
|
|
7
|
+
* loop: it exists for the duration of a request or a scheduled event and then stops. There
|
|
8
|
+
* is no timer to hang a poller off, so the mechanism does not port — it is replaced.
|
|
9
|
+
*
|
|
10
|
+
* ## 🔴 The measurement that makes this cheaper than the task file assumed
|
|
11
|
+
*
|
|
12
|
+
* Re-measured 2026-09-16 over `apps/` and `libs/`: all **12** `plainjob` files are in
|
|
13
|
+
* `libs/` (ten in `cursedbelt-server`, two in `cursedbelt-cc`), and **zero** are in
|
|
14
|
+
* `apps/`. No app declares a job through this engine yet. The original table read "12
|
|
15
|
+
* files using plainjob" as twelve call sites to port; they are one mechanism in one
|
|
16
|
+
* library, which is this seam. The port is a library change, not a fleet sweep.
|
|
17
|
+
*
|
|
18
|
+
* That also means the shape below is free to be the right one rather than the compatible
|
|
19
|
+
* one — there is no app-side caller to keep happy.
|
|
20
|
+
*
|
|
21
|
+
* ## The three primitives, and the rule for choosing
|
|
22
|
+
*
|
|
23
|
+
* | primitive | cost | when it is the answer |
|
|
24
|
+
* |---|---|---|
|
|
25
|
+
* | **Cron Triggers** | free | anything periodic at ≥ 1-minute cadence. Nightly backups and ingests — most of this fleet |
|
|
26
|
+
* | **Queues** | $0.40/M operations, 1M included | work fanned out FROM a request, where the response must not wait |
|
|
27
|
+
* | **Durable Object Alarms** | DO pricing | per-entity scheduling: one timer per row, set from the row's own lifecycle |
|
|
28
|
+
*
|
|
29
|
+
* 🔴 **The hard discriminator is cadence, and it is a real cliff.** Cloudflare Cron
|
|
30
|
+
* Triggers take a **5-field** expression with a **one-minute floor**. `JobDef.cron` accepts
|
|
31
|
+
* a 6-field expression with a leading seconds column, and this repo already contains three
|
|
32
|
+
* jobs declared `'* * * * * *'` — once per second. Those cannot become Cron Triggers at
|
|
33
|
+
* all, and {@link classifySchedule} says so by name instead of silently rounding them up to
|
|
34
|
+
* a minute, which would change behaviour in production and nowhere else.
|
|
35
|
+
*/
|
|
36
|
+
/** Cloudflare Queues: $0.40 per million operations, first million free each month. */
|
|
37
|
+
const QUEUE_USD_PER_MILLION = 0.4;
|
|
38
|
+
const QUEUE_FREE_OPERATIONS = 1_000_000;
|
|
39
|
+
/**
|
|
40
|
+
* A cron expression with six fields carries a leading SECONDS column. Cloudflare has no
|
|
41
|
+
* such column and no cadence below one minute.
|
|
42
|
+
*/
|
|
43
|
+
export function cronFieldCount(cron) {
|
|
44
|
+
return cron.trim().split(/\s+/).filter(Boolean).length;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Drop the seconds column from a 6-field expression, reporting whether doing so changes
|
|
48
|
+
* when the job fires. `'0 3 * * * *'` is 3 a.m. daily either way; `'* * * * * *'` is not.
|
|
49
|
+
*/
|
|
50
|
+
export function toFiveField(cron) {
|
|
51
|
+
const parts = cron.trim().split(/\s+/).filter(Boolean);
|
|
52
|
+
if (parts.length <= 5)
|
|
53
|
+
return { cron: parts.join(' '), lossless: true };
|
|
54
|
+
const [seconds, ...rest] = parts;
|
|
55
|
+
// Only a fixed single second (`0`, `30`, …) survives the drop: it means "once, at that
|
|
56
|
+
// second of the matching minute", which a minute-granularity trigger reproduces. A
|
|
57
|
+
// wildcard or a step means sub-minute cadence, which it cannot.
|
|
58
|
+
const lossless = /^\d+$/.test(seconds ?? '');
|
|
59
|
+
return { cron: rest.join(' '), lossless };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Choose the Workers primitive for a piece of scheduled work.
|
|
63
|
+
*
|
|
64
|
+
* Returns `'unportable'` rather than a best guess when the cadence is below Cloudflare's
|
|
65
|
+
* floor — a job that silently became 60× less frequent in production would be found by a
|
|
66
|
+
* user, not by a check.
|
|
67
|
+
*/
|
|
68
|
+
export function classifySchedule(shape) {
|
|
69
|
+
switch (shape.kind) {
|
|
70
|
+
case 'periodic': {
|
|
71
|
+
const fields = cronFieldCount(shape.cron);
|
|
72
|
+
const { cron, lossless } = toFiveField(shape.cron);
|
|
73
|
+
if (fields > 5 && !lossless) {
|
|
74
|
+
return {
|
|
75
|
+
primitive: 'unportable',
|
|
76
|
+
reason: `'${shape.cron}' fires below Cloudflare's one-minute floor. A Cron Trigger cannot express it. ` +
|
|
77
|
+
'Either relax the cadence to ≥ 1 minute, or keep this job on a host with a real timer ' +
|
|
78
|
+
'(it is a poller, and a poller is usually a Queue consumer in disguise).',
|
|
79
|
+
cron: null,
|
|
80
|
+
estimatedMonthlyUsd: 0,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
primitive: 'cron-trigger',
|
|
85
|
+
reason: 'Periodic at ≥ 1-minute cadence — a Cron Trigger, which is free and needs no table, no poll ' +
|
|
86
|
+
'and no process. This is most of the fleet: nightly backups, ingests and metric flushes.',
|
|
87
|
+
cron,
|
|
88
|
+
estimatedMonthlyUsd: 0,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
case 'fanned-out-from-request': {
|
|
92
|
+
const perMonth = (shape.expectedPerDay ?? 0) * 30;
|
|
93
|
+
// Each message is counted on write and on read, so a delivered message is two
|
|
94
|
+
// operations. Pricing it as one is the mistake that makes Queues look half price.
|
|
95
|
+
const operations = perMonth * 2;
|
|
96
|
+
const billable = Math.max(0, operations - QUEUE_FREE_OPERATIONS);
|
|
97
|
+
return {
|
|
98
|
+
primitive: 'queue',
|
|
99
|
+
reason: 'Work fanned out from a request, where the response must not wait for it. A Queue decouples the ' +
|
|
100
|
+
'two and retries on failure. 1M operations are included each month; a delivered message costs two ' +
|
|
101
|
+
'(one write, one read).',
|
|
102
|
+
cron: null,
|
|
103
|
+
estimatedMonthlyUsd: Number(((billable / 1_000_000) * QUEUE_USD_PER_MILLION).toFixed(4)),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
case 'per-entity':
|
|
107
|
+
return {
|
|
108
|
+
primitive: 'durable-object-alarm',
|
|
109
|
+
reason: 'One timer per entity, set from that entity’s own lifecycle — a reminder on a row, a lease that ' +
|
|
110
|
+
'expires. A Cron Trigger would have to scan every row every minute to find the few that are due; ' +
|
|
111
|
+
'an alarm is woken only for the one that is.',
|
|
112
|
+
cron: null,
|
|
113
|
+
// A DO is only billed for the requests and duration it actually uses; an idle
|
|
114
|
+
// alarm costs nothing until it fires. The real number depends on entity count,
|
|
115
|
+
// so a fabricated figure here would be worse than none.
|
|
116
|
+
estimatedMonthlyUsd: 0,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Render the `[triggers]` block of a `wrangler.toml` from a job list — and refuse to render
|
|
122
|
+
* one that silently drops a job.
|
|
123
|
+
*
|
|
124
|
+
* 🔴 It throws on an unportable job rather than skipping it. A generated config that
|
|
125
|
+
* quietly omits a job is a job that stops running in production while every local test
|
|
126
|
+
* still passes, which is the failure mode this whole seam exists to prevent.
|
|
127
|
+
*/
|
|
128
|
+
export function toCronTriggers(jobs) {
|
|
129
|
+
const crons = [];
|
|
130
|
+
const queues = [];
|
|
131
|
+
const unportable = [];
|
|
132
|
+
for (const job of jobs) {
|
|
133
|
+
const verdict = classifySchedule(job.shape);
|
|
134
|
+
if (verdict.primitive === 'unportable') {
|
|
135
|
+
unportable.push(` ${job.name}: ${verdict.reason}`);
|
|
136
|
+
}
|
|
137
|
+
else if (verdict.primitive === 'cron-trigger' && verdict.cron) {
|
|
138
|
+
if (!crons.includes(verdict.cron))
|
|
139
|
+
crons.push(verdict.cron);
|
|
140
|
+
}
|
|
141
|
+
else if (verdict.primitive === 'queue') {
|
|
142
|
+
queues.push(job.name);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (unportable.length > 0) {
|
|
146
|
+
throw new Error(`${unportable.length} job(s) cannot be expressed as a Cloudflare trigger:\n${unportable.join('\n')}\n` +
|
|
147
|
+
'Fix the cadence or keep the job on a host — do not ship a config that omits it.');
|
|
148
|
+
}
|
|
149
|
+
return { crons, queues };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* 🔴 Cloudflare dispatches a Cron Trigger by EXPRESSION, not by job name — the
|
|
153
|
+
* `scheduled` handler is told which cron fired and nothing else. Two jobs sharing
|
|
154
|
+
* `0 3 * * *` arrive as one event, so the Worker has to fan back out to every job matching
|
|
155
|
+
* that expression. Forgetting this runs the first job and silently never runs the second.
|
|
156
|
+
*/
|
|
157
|
+
export function jobsForCron(jobs, firedCron) {
|
|
158
|
+
return jobs.filter((job) => {
|
|
159
|
+
if (job.shape.kind !== 'periodic')
|
|
160
|
+
return false;
|
|
161
|
+
const verdict = classifySchedule(job.shape);
|
|
162
|
+
return verdict.primitive === 'cron-trigger' && verdict.cron === firedCron.trim();
|
|
163
|
+
});
|
|
164
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The async database seam every app crosses to reach SQLite — locally on `bun:sqlite`,
|
|
3
|
+
* remotely on Cloudflare D1.
|
|
4
|
+
*
|
|
5
|
+
* ## 🔴 It is async on BOTH sides, and that is the entire point
|
|
6
|
+
*
|
|
7
|
+
* `bun:sqlite` is synchronous: `db.query(sql).all()` returns rows. D1 is
|
|
8
|
+
* `await db.prepare(sql).all()`. No adapter, no proxy and no `Proxy` trap makes a
|
|
9
|
+
* synchronous call site await — so the port is a real edit at every call site, and the
|
|
10
|
+
* only question this seam answers is whether that edit has to happen TWICE.
|
|
11
|
+
*
|
|
12
|
+
* A seam that is sync locally and async remotely passes every test on this machine and
|
|
13
|
+
* fails in production. So {@link D1LikeDatabase} is the same async shape on both sides,
|
|
14
|
+
* the local driver is the one that changes, and a ported call site runs unmodified
|
|
15
|
+
* against either.
|
|
16
|
+
*
|
|
17
|
+
* ## 🔴 The local driver is as STRICT as the remote one, never as lenient as Bun
|
|
18
|
+
*
|
|
19
|
+
* The same argument applies one level down, and it is where the real bugs live. Measured
|
|
20
|
+
* against `bun:sqlite` on 2026-09-16, four things differ between the two drivers, and two
|
|
21
|
+
* of them are silent-here / throw-there:
|
|
22
|
+
*
|
|
23
|
+
* | value | `bun:sqlite` | D1 (`workerd`) |
|
|
24
|
+
* |----------------------|-------------------------------|-------------------------|
|
|
25
|
+
* | `undefined` bind | 🔴 silently binds NULL | throws |
|
|
26
|
+
* | `boolean` bind | 🔴 silently coerces to 0/1 | throws |
|
|
27
|
+
* | BLOB column read | `Uint8Array` | `number[]` |
|
|
28
|
+
* | `.run()` result | `{changes, lastInsertRowid}` | `{success, meta, …}` |
|
|
29
|
+
*
|
|
30
|
+
* Lenient-locally is the same defect as sync-locally wearing a different costume: it is
|
|
31
|
+
* green on this Mac and red in the Worker. `normalizeBind` in `./values` therefore makes
|
|
32
|
+
* the LOCAL driver refuse what D1 refuses, and normalizes what both accept to one shape.
|
|
33
|
+
* The one deliberate exception is `boolean`, which is coerced to 0/1 on both sides rather
|
|
34
|
+
* than refused on both: SQLite has no boolean type, so 0/1 is not a guess about intent,
|
|
35
|
+
* and refusing it would break every natural `where('disabled', '=', false)` in the fleet.
|
|
36
|
+
* `undefined` is refused, because "missing argument" and "intentional NULL" are genuinely
|
|
37
|
+
* different and only the caller knows which was meant.
|
|
38
|
+
*
|
|
39
|
+
* ## Transactions are refused on both sides, and `batch()` is the answer
|
|
40
|
+
*
|
|
41
|
+
* D1 has no interactive transaction — there is no `BEGIN`/`COMMIT` over HTTP, only
|
|
42
|
+
* {@link D1LikeDatabase.batch}, which is atomic and runs as one round trip. A local
|
|
43
|
+
* driver that happily honoured `BEGIN` would be the sync-vs-async trap a third time, so
|
|
44
|
+
* {@link D1UnsupportedError} is thrown by both. Wrap the statements in `batch()` instead;
|
|
45
|
+
* it is atomic on D1 and wrapped in a real `bun:sqlite` transaction locally, so the
|
|
46
|
+
* guarantee is the same.
|
|
47
|
+
*
|
|
48
|
+
* The shape below deliberately mirrors Cloudflare's own `D1Database` rather than
|
|
49
|
+
* inventing a vocabulary: the remote driver is then close to a pass-through, and the
|
|
50
|
+
* thing an app author reads in Cloudflare's docs is the thing they have.
|
|
51
|
+
*/
|
|
52
|
+
/** A row as it comes back from either driver — column name to normalized value. */
|
|
53
|
+
export type D1LikeRow = Record<string, D1LikeValue>;
|
|
54
|
+
/**
|
|
55
|
+
* Every value either driver can RETURN, after normalization. Note there is no `boolean`
|
|
56
|
+
* and no `bigint`: SQLite stores neither, and a driver that invented one on read would
|
|
57
|
+
* disagree with the other driver on the way back in.
|
|
58
|
+
*/
|
|
59
|
+
export type D1LikeValue = string | number | null | Uint8Array;
|
|
60
|
+
/**
|
|
61
|
+
* Every value either driver accepts as a BOUND parameter. `boolean` is accepted and
|
|
62
|
+
* coerced to 0/1; `bigint` is accepted within the safe-integer range and refused outside
|
|
63
|
+
* it (where it could not survive the round trip anyway). `undefined` is absent on
|
|
64
|
+
* purpose — see the header.
|
|
65
|
+
*/
|
|
66
|
+
export type D1LikeBindable = string | number | boolean | bigint | null | Uint8Array | ArrayBuffer;
|
|
67
|
+
/**
|
|
68
|
+
* What a statement reports about its own execution. Mirrors D1's `meta`, with the fields
|
|
69
|
+
* the local driver can honestly fill. A field the local driver cannot know is `null`
|
|
70
|
+
* rather than a plausible-looking zero — a fabricated `rows_read` of 0 would read as a
|
|
71
|
+
* measurement in `192`'s CPU accounting rather than as an absence.
|
|
72
|
+
*/
|
|
73
|
+
export interface D1LikeMeta {
|
|
74
|
+
/** Rows changed by the statement. `0` for a read. */
|
|
75
|
+
changes: number;
|
|
76
|
+
/** Rowid of the last insert, or `null` when the statement inserted nothing. */
|
|
77
|
+
last_row_id: number | null;
|
|
78
|
+
/** Wall-clock milliseconds the driver spent on the statement. */
|
|
79
|
+
duration: number;
|
|
80
|
+
/** D1 only — rows the query engine read. `null` locally, where nothing counts them. */
|
|
81
|
+
rows_read: number | null;
|
|
82
|
+
/** D1 only — rows the query engine wrote. `null` locally. */
|
|
83
|
+
rows_written: number | null;
|
|
84
|
+
}
|
|
85
|
+
/** The result of `.all()` / `.run()` on either driver. */
|
|
86
|
+
export interface D1LikeResult<T = D1LikeRow> {
|
|
87
|
+
results: T[];
|
|
88
|
+
success: true;
|
|
89
|
+
meta: D1LikeMeta;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* A prepared statement. `bind()` returns a NEW statement rather than mutating this one,
|
|
93
|
+
* which is D1's own contract and what makes a statement safe to keep and re-bind.
|
|
94
|
+
*/
|
|
95
|
+
export interface D1LikeStatement {
|
|
96
|
+
bind(...values: D1LikeBindable[]): D1LikeStatement;
|
|
97
|
+
/** The first row, or `null` when the query matched nothing. */
|
|
98
|
+
first<T = D1LikeRow>(): Promise<T | null>;
|
|
99
|
+
/** One column of the first row, or `null` when the query matched nothing. */
|
|
100
|
+
first<V = D1LikeValue>(column: string): Promise<V | null>;
|
|
101
|
+
all<T = D1LikeRow>(): Promise<D1LikeResult<T>>;
|
|
102
|
+
run(): Promise<D1LikeResult<never>>;
|
|
103
|
+
/** Rows as positional arrays rather than objects — the cheap shape for bulk reads. */
|
|
104
|
+
raw<V = D1LikeValue>(): Promise<V[][]>;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The database handle an app holds. One of these is constructed per request on a Worker
|
|
108
|
+
* and once per process locally; nothing below this line knows which it got.
|
|
109
|
+
*/
|
|
110
|
+
export interface D1LikeDatabase {
|
|
111
|
+
prepare(sql: string): D1LikeStatement;
|
|
112
|
+
/**
|
|
113
|
+
* Run several statements atomically as ONE round trip. This is the transaction
|
|
114
|
+
* primitive — see the header for why there is no `begin()`.
|
|
115
|
+
*/
|
|
116
|
+
batch<T = D1LikeRow>(statements: D1LikeStatement[]): Promise<D1LikeResult<T>[]>;
|
|
117
|
+
/**
|
|
118
|
+
* Run one or more statements for their side effects, with no bound parameters —
|
|
119
|
+
* schema migrations, `PRAGMA`s, DDL. Not for anything carrying user input.
|
|
120
|
+
*/
|
|
121
|
+
exec(sql: string): Promise<{
|
|
122
|
+
count: number;
|
|
123
|
+
duration: number;
|
|
124
|
+
}>;
|
|
125
|
+
/** Which side of the seam this handle is. Lets a caller branch where it genuinely must. */
|
|
126
|
+
readonly flavor: 'local' | 'd1';
|
|
127
|
+
}
|
|
128
|
+
/** Thrown when a bound parameter is of a type D1 will not accept. */
|
|
129
|
+
export declare class D1BindError extends TypeError {
|
|
130
|
+
readonly index: number;
|
|
131
|
+
constructor(index: number, message: string);
|
|
132
|
+
}
|
|
133
|
+
/** Thrown for an operation D1 cannot perform, so the local driver refuses it too. */
|
|
134
|
+
export declare class D1UnsupportedError extends Error {
|
|
135
|
+
constructor(what: string, instead: string);
|
|
136
|
+
}
|
|
137
|
+
/** Thrown when a statement would exceed one of D1's hard limits. See `./limits`. */
|
|
138
|
+
export declare class D1LimitError extends RangeError {
|
|
139
|
+
readonly limit: string;
|
|
140
|
+
readonly actual: number;
|
|
141
|
+
readonly max: number;
|
|
142
|
+
constructor(limit: string, actual: number, max: number, remedy: string);
|
|
143
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The async database seam every app crosses to reach SQLite — locally on `bun:sqlite`,
|
|
3
|
+
* remotely on Cloudflare D1.
|
|
4
|
+
*
|
|
5
|
+
* ## 🔴 It is async on BOTH sides, and that is the entire point
|
|
6
|
+
*
|
|
7
|
+
* `bun:sqlite` is synchronous: `db.query(sql).all()` returns rows. D1 is
|
|
8
|
+
* `await db.prepare(sql).all()`. No adapter, no proxy and no `Proxy` trap makes a
|
|
9
|
+
* synchronous call site await — so the port is a real edit at every call site, and the
|
|
10
|
+
* only question this seam answers is whether that edit has to happen TWICE.
|
|
11
|
+
*
|
|
12
|
+
* A seam that is sync locally and async remotely passes every test on this machine and
|
|
13
|
+
* fails in production. So {@link D1LikeDatabase} is the same async shape on both sides,
|
|
14
|
+
* the local driver is the one that changes, and a ported call site runs unmodified
|
|
15
|
+
* against either.
|
|
16
|
+
*
|
|
17
|
+
* ## 🔴 The local driver is as STRICT as the remote one, never as lenient as Bun
|
|
18
|
+
*
|
|
19
|
+
* The same argument applies one level down, and it is where the real bugs live. Measured
|
|
20
|
+
* against `bun:sqlite` on 2026-09-16, four things differ between the two drivers, and two
|
|
21
|
+
* of them are silent-here / throw-there:
|
|
22
|
+
*
|
|
23
|
+
* | value | `bun:sqlite` | D1 (`workerd`) |
|
|
24
|
+
* |----------------------|-------------------------------|-------------------------|
|
|
25
|
+
* | `undefined` bind | 🔴 silently binds NULL | throws |
|
|
26
|
+
* | `boolean` bind | 🔴 silently coerces to 0/1 | throws |
|
|
27
|
+
* | BLOB column read | `Uint8Array` | `number[]` |
|
|
28
|
+
* | `.run()` result | `{changes, lastInsertRowid}` | `{success, meta, …}` |
|
|
29
|
+
*
|
|
30
|
+
* Lenient-locally is the same defect as sync-locally wearing a different costume: it is
|
|
31
|
+
* green on this Mac and red in the Worker. `normalizeBind` in `./values` therefore makes
|
|
32
|
+
* the LOCAL driver refuse what D1 refuses, and normalizes what both accept to one shape.
|
|
33
|
+
* The one deliberate exception is `boolean`, which is coerced to 0/1 on both sides rather
|
|
34
|
+
* than refused on both: SQLite has no boolean type, so 0/1 is not a guess about intent,
|
|
35
|
+
* and refusing it would break every natural `where('disabled', '=', false)` in the fleet.
|
|
36
|
+
* `undefined` is refused, because "missing argument" and "intentional NULL" are genuinely
|
|
37
|
+
* different and only the caller knows which was meant.
|
|
38
|
+
*
|
|
39
|
+
* ## Transactions are refused on both sides, and `batch()` is the answer
|
|
40
|
+
*
|
|
41
|
+
* D1 has no interactive transaction — there is no `BEGIN`/`COMMIT` over HTTP, only
|
|
42
|
+
* {@link D1LikeDatabase.batch}, which is atomic and runs as one round trip. A local
|
|
43
|
+
* driver that happily honoured `BEGIN` would be the sync-vs-async trap a third time, so
|
|
44
|
+
* {@link D1UnsupportedError} is thrown by both. Wrap the statements in `batch()` instead;
|
|
45
|
+
* it is atomic on D1 and wrapped in a real `bun:sqlite` transaction locally, so the
|
|
46
|
+
* guarantee is the same.
|
|
47
|
+
*
|
|
48
|
+
* The shape below deliberately mirrors Cloudflare's own `D1Database` rather than
|
|
49
|
+
* inventing a vocabulary: the remote driver is then close to a pass-through, and the
|
|
50
|
+
* thing an app author reads in Cloudflare's docs is the thing they have.
|
|
51
|
+
*/
|
|
52
|
+
/** Thrown when a bound parameter is of a type D1 will not accept. */
|
|
53
|
+
export class D1BindError extends TypeError {
|
|
54
|
+
index;
|
|
55
|
+
constructor(index, message) {
|
|
56
|
+
super(`parameter ${index + 1}: ${message}`);
|
|
57
|
+
this.name = 'D1BindError';
|
|
58
|
+
this.index = index;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Thrown for an operation D1 cannot perform, so the local driver refuses it too. */
|
|
62
|
+
export class D1UnsupportedError extends Error {
|
|
63
|
+
constructor(what, instead) {
|
|
64
|
+
super(`${what} is not supported on D1 — ${instead}`);
|
|
65
|
+
this.name = 'D1UnsupportedError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Thrown when a statement would exceed one of D1's hard limits. See `./limits`. */
|
|
69
|
+
export class D1LimitError extends RangeError {
|
|
70
|
+
limit;
|
|
71
|
+
actual;
|
|
72
|
+
max;
|
|
73
|
+
constructor(limit, actual, max, remedy) {
|
|
74
|
+
super(`D1 limit '${limit}' exceeded: ${actual} > ${max}. ${remedy}`);
|
|
75
|
+
this.name = 'D1LimitError';
|
|
76
|
+
this.limit = limit;
|
|
77
|
+
this.actual = actual;
|
|
78
|
+
this.max = max;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that decides what a bound parameter and a returned column MEAN, shared by
|
|
3
|
+
* both drivers.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 **Both drivers call these, and that is why the identical-shapes test can pass.** If
|
|
6
|
+
* the local driver normalized its own way and the remote driver normalized its own way,
|
|
7
|
+
* the test in `sameShape.spec.ts` would be asserting that two independent implementations
|
|
8
|
+
* happen to agree today — which is a coincidence with a maintenance schedule. Here there
|
|
9
|
+
* is one implementation of the meaning and two implementations of the transport.
|
|
10
|
+
*
|
|
11
|
+
* Every rule below was measured against `bun:sqlite` on 2026-09-16 (`Bun 1.3`), not
|
|
12
|
+
* recalled: the probe wrote each type into a `CREATE TABLE t (v)` column and read it back.
|
|
13
|
+
*/
|
|
14
|
+
import { type D1LikeBindable, type D1LikeMeta, type D1LikeValue } from './types';
|
|
15
|
+
/**
|
|
16
|
+
* Normalize one bound parameter to something BOTH drivers accept, or throw.
|
|
17
|
+
*
|
|
18
|
+
* The local driver is deliberately made as strict as D1 here. `bun:sqlite` accepts
|
|
19
|
+
* `undefined` (binding NULL) and `boolean` (coercing to 0/1) where D1 throws; a local
|
|
20
|
+
* driver that inherited that leniency would go green on this Mac and red in the Worker,
|
|
21
|
+
* which is the same defect as a sync seam.
|
|
22
|
+
*/
|
|
23
|
+
export declare function normalizeBind(value: unknown, index: number): D1LikeBindable;
|
|
24
|
+
/** Normalize a whole parameter list, reporting the offending position on failure. */
|
|
25
|
+
export declare const normalizeBinds: (values: readonly unknown[]) => D1LikeBindable[];
|
|
26
|
+
/**
|
|
27
|
+
* Normalize one column value coming BACK from a driver.
|
|
28
|
+
*
|
|
29
|
+
* The measured disagreement is BLOBs: `bun:sqlite` returns a `Uint8Array`, D1 returns a
|
|
30
|
+
* plain `number[]` (it crosses the wire as JSON). `Uint8Array` wins on both — it is the
|
|
31
|
+
* type every consumer of a blob actually wants, and `Array.isArray` is a reliable way to
|
|
32
|
+
* spot D1's shape because no other column type returns an array.
|
|
33
|
+
*/
|
|
34
|
+
export declare function normalizeValue(value: unknown): D1LikeValue;
|
|
35
|
+
/** Normalize a row object, preserving column order. */
|
|
36
|
+
export declare function normalizeRow<T>(row: Record<string, unknown>): T;
|
|
37
|
+
/** Normalize a whole result set. */
|
|
38
|
+
export declare const normalizeRows: <T>(rows: readonly Record<string, unknown>[]) => T[];
|
|
39
|
+
/**
|
|
40
|
+
* Build a {@link D1LikeMeta}. `rows_read`/`rows_written` default to `null` — the local
|
|
41
|
+
* driver genuinely cannot count them, and a plausible-looking `0` would be read as a
|
|
42
|
+
* measurement by `192`'s CPU accounting rather than as an absence.
|
|
43
|
+
*/
|
|
44
|
+
export declare function makeMeta(partial: {
|
|
45
|
+
changes?: number;
|
|
46
|
+
last_row_id?: number | bigint | null;
|
|
47
|
+
duration: number;
|
|
48
|
+
rows_read?: number | null;
|
|
49
|
+
rows_written?: number | null;
|
|
50
|
+
}): D1LikeMeta;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that decides what a bound parameter and a returned column MEAN, shared by
|
|
3
|
+
* both drivers.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 **Both drivers call these, and that is why the identical-shapes test can pass.** If
|
|
6
|
+
* the local driver normalized its own way and the remote driver normalized its own way,
|
|
7
|
+
* the test in `sameShape.spec.ts` would be asserting that two independent implementations
|
|
8
|
+
* happen to agree today — which is a coincidence with a maintenance schedule. Here there
|
|
9
|
+
* is one implementation of the meaning and two implementations of the transport.
|
|
10
|
+
*
|
|
11
|
+
* Every rule below was measured against `bun:sqlite` on 2026-09-16 (`Bun 1.3`), not
|
|
12
|
+
* recalled: the probe wrote each type into a `CREATE TABLE t (v)` column and read it back.
|
|
13
|
+
*/
|
|
14
|
+
import { D1BindError } from './types';
|
|
15
|
+
/**
|
|
16
|
+
* The largest integer a JS number holds exactly. A `bigint` beyond this cannot survive the
|
|
17
|
+
* round trip through either driver — measured, `bun:sqlite` returns `9007199254740992` for
|
|
18
|
+
* a stored `9007199254740993` — so it is refused rather than silently rounded.
|
|
19
|
+
*/
|
|
20
|
+
const SAFE = BigInt(Number.MAX_SAFE_INTEGER);
|
|
21
|
+
/**
|
|
22
|
+
* Normalize one bound parameter to something BOTH drivers accept, or throw.
|
|
23
|
+
*
|
|
24
|
+
* The local driver is deliberately made as strict as D1 here. `bun:sqlite` accepts
|
|
25
|
+
* `undefined` (binding NULL) and `boolean` (coercing to 0/1) where D1 throws; a local
|
|
26
|
+
* driver that inherited that leniency would go green on this Mac and red in the Worker,
|
|
27
|
+
* which is the same defect as a sync seam.
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeBind(value, index) {
|
|
30
|
+
if (value === null)
|
|
31
|
+
return null;
|
|
32
|
+
switch (typeof value) {
|
|
33
|
+
case 'string':
|
|
34
|
+
case 'number':
|
|
35
|
+
// 🔴 NaN and ±Infinity have no SQLite representation. `bun:sqlite` stores NaN as
|
|
36
|
+
// NULL; D1 rejects it. Refusing both is the only answer that reads the same.
|
|
37
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
38
|
+
throw new D1BindError(index, `${value} has no SQLite representation — store null, or a string`);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
// SQLite has no boolean type: 0/1 is the storage, not a guess about intent. Coerced
|
|
42
|
+
// on BOTH sides so the two agree, rather than refused on both, which would break
|
|
43
|
+
// every natural `where('disabled', '=', false)` in the fleet.
|
|
44
|
+
case 'boolean':
|
|
45
|
+
return value ? 1 : 0;
|
|
46
|
+
case 'bigint':
|
|
47
|
+
if (value > SAFE || value < -SAFE) {
|
|
48
|
+
throw new D1BindError(index, `bigint ${value} is outside the safe-integer range and cannot round-trip — store it as TEXT`);
|
|
49
|
+
}
|
|
50
|
+
return Number(value);
|
|
51
|
+
case 'undefined':
|
|
52
|
+
// 🔴 The measured trap. `bun:sqlite` binds NULL and says nothing; D1 throws.
|
|
53
|
+
// "Missing argument" and "intentional NULL" are different, and only the caller
|
|
54
|
+
// knows which was meant — so the caller says so.
|
|
55
|
+
throw new D1BindError(index, 'undefined — pass null explicitly if you mean SQL NULL');
|
|
56
|
+
case 'object':
|
|
57
|
+
if (value instanceof Uint8Array)
|
|
58
|
+
return value;
|
|
59
|
+
if (value instanceof ArrayBuffer)
|
|
60
|
+
return new Uint8Array(value);
|
|
61
|
+
if (value instanceof Date) {
|
|
62
|
+
// Common enough to deserve its own sentence rather than "not supported".
|
|
63
|
+
throw new D1BindError(index, 'Date — store an ISO string (`d.toISOString()`) or an epoch number');
|
|
64
|
+
}
|
|
65
|
+
throw new D1BindError(index, `${value.constructor?.name ?? 'object'} — bind a string, number, null or Uint8Array`);
|
|
66
|
+
default:
|
|
67
|
+
throw new D1BindError(index, `${typeof value} is not bindable`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Normalize a whole parameter list, reporting the offending position on failure. */
|
|
71
|
+
export const normalizeBinds = (values) => values.map((v, i) => normalizeBind(v, i));
|
|
72
|
+
/**
|
|
73
|
+
* Normalize one column value coming BACK from a driver.
|
|
74
|
+
*
|
|
75
|
+
* The measured disagreement is BLOBs: `bun:sqlite` returns a `Uint8Array`, D1 returns a
|
|
76
|
+
* plain `number[]` (it crosses the wire as JSON). `Uint8Array` wins on both — it is the
|
|
77
|
+
* type every consumer of a blob actually wants, and `Array.isArray` is a reliable way to
|
|
78
|
+
* spot D1's shape because no other column type returns an array.
|
|
79
|
+
*/
|
|
80
|
+
export function normalizeValue(value) {
|
|
81
|
+
if (value === null || value === undefined)
|
|
82
|
+
return null;
|
|
83
|
+
if (value instanceof Uint8Array)
|
|
84
|
+
return value;
|
|
85
|
+
if (value instanceof ArrayBuffer)
|
|
86
|
+
return new Uint8Array(value);
|
|
87
|
+
if (Array.isArray(value))
|
|
88
|
+
return Uint8Array.from(value);
|
|
89
|
+
if (typeof value === 'bigint')
|
|
90
|
+
return Number(value);
|
|
91
|
+
if (typeof value === 'boolean')
|
|
92
|
+
return value ? 1 : 0;
|
|
93
|
+
if (typeof value === 'string' || typeof value === 'number')
|
|
94
|
+
return value;
|
|
95
|
+
// Nothing else can come out of either driver; if it does, that is a driver change we
|
|
96
|
+
// want to hear about loudly rather than pass through as an opaque object.
|
|
97
|
+
throw new TypeError(`unexpected column value of type ${typeof value} from the database driver`);
|
|
98
|
+
}
|
|
99
|
+
/** Normalize a row object, preserving column order. */
|
|
100
|
+
export function normalizeRow(row) {
|
|
101
|
+
const out = {};
|
|
102
|
+
for (const key of Object.keys(row))
|
|
103
|
+
out[key] = normalizeValue(row[key]);
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
/** Normalize a whole result set. */
|
|
107
|
+
export const normalizeRows = (rows) => rows.map((r) => normalizeRow(r));
|
|
108
|
+
/**
|
|
109
|
+
* Build a {@link D1LikeMeta}. `rows_read`/`rows_written` default to `null` — the local
|
|
110
|
+
* driver genuinely cannot count them, and a plausible-looking `0` would be read as a
|
|
111
|
+
* measurement by `192`'s CPU accounting rather than as an absence.
|
|
112
|
+
*/
|
|
113
|
+
export function makeMeta(partial) {
|
|
114
|
+
const raw = partial.last_row_id ?? null;
|
|
115
|
+
return {
|
|
116
|
+
changes: partial.changes ?? 0,
|
|
117
|
+
// A rowid of 0 means "nothing was inserted" in both drivers; report it as absent
|
|
118
|
+
// so a caller can `?? ` it rather than having to know that 0 is a sentinel.
|
|
119
|
+
last_row_id: raw === null || Number(raw) === 0 ? null : Number(raw),
|
|
120
|
+
duration: partial.duration,
|
|
121
|
+
rows_read: partial.rows_read ?? null,
|
|
122
|
+
rows_written: partial.rows_written ?? null,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { Hono } from "hono";
|
|
12
12
|
import type { OpLog } from "./opLog";
|
|
13
13
|
import type { ApplyOne, ApplyReport, RemoteApi } from "./types";
|
|
14
|
+
import { type SyncSignalHub } from "./signal";
|
|
14
15
|
/**
|
|
15
16
|
* Trim a page to the byte budget, keeping it a PREFIX so `cursor` stays the seq
|
|
16
17
|
* of the last op actually returned. Anything else silently drops ops: the caller
|
|
@@ -26,8 +27,6 @@ export interface ReceiverHooks {
|
|
|
26
27
|
/** Every authenticated hit — the only evidence a non-dialing half has that it is
|
|
27
28
|
* in step (vault's `lastPeerContactAt` lesson). */
|
|
28
29
|
onPeerContact?: (peerId: string) => void;
|
|
29
|
-
/** The "Sync now was pressed HERE" stamp the dialer polls. */
|
|
30
|
-
readRequest?: () => number | null;
|
|
31
30
|
}
|
|
32
31
|
export interface ReceiverOptions {
|
|
33
32
|
log: OpLog;
|
|
@@ -58,8 +57,26 @@ export interface ReceiverOptions {
|
|
|
58
57
|
*/
|
|
59
58
|
afterBatch?: (report: ApplyReport) => void | Promise<void>;
|
|
60
59
|
hooks?: ReceiverHooks;
|
|
60
|
+
/**
|
|
61
|
+
* Mount `GET /signal` — the long-lived stream that replaced the dialer's
|
|
62
|
+
* 20-second `GET /requested` probe. Omitted ⇒ the route does not exist, the
|
|
63
|
+
* orch-companion idiom: nothing to probe, nothing to authenticate against.
|
|
64
|
+
*
|
|
65
|
+
* Call {@link SyncSignalHub.announce} after a local write on THIS half and the
|
|
66
|
+
* dialing half reconciles within the round trip. See `./signal.ts`.
|
|
67
|
+
*/
|
|
68
|
+
signal?: SyncSignalHub;
|
|
61
69
|
}
|
|
62
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* Build the receiver: GET /info, GET /pull, POST /push.
|
|
72
|
+
*
|
|
73
|
+
* 🔴 There was a fourth route, `GET /requested`, and it is gone (2026-09-15). It
|
|
74
|
+
* served a single integer — "was Sync now pressed here?" — and existed only to be
|
|
75
|
+
* asked, every 20 seconds, by a dialer that otherwise had nothing to say. That made
|
|
76
|
+
* it **68 % of `vault`'s entire traffic**. A receiver with news now says so over
|
|
77
|
+
* {@link import("./signal")} instead of waiting to be asked; deleting the route is
|
|
78
|
+
* what stops a stale consumer from keeping the poll alive against a new build.
|
|
79
|
+
*/
|
|
63
80
|
export declare function createSyncReceiver(options: ReceiverOptions): Hono;
|
|
64
81
|
/**
|
|
65
82
|
* The fetch-backed remote the dialer binds to. `basePath` is where the peer
|
package/dist/server/sync/http.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { Hono } from "hono";
|
|
12
12
|
import { createSyncApplier } from "./engine";
|
|
13
|
+
import { signalResponse } from "./signal";
|
|
13
14
|
const noStore = { "cache-control": "no-store" };
|
|
14
15
|
const SYNC_PAGE = 500;
|
|
15
16
|
/**
|
|
@@ -52,7 +53,16 @@ function peerIdOf(header, query) {
|
|
|
52
53
|
const raw = query ?? header ?? "";
|
|
53
54
|
return /^[0-9A-Za-z_-]{1,64}$/.test(raw) ? raw : "peer";
|
|
54
55
|
}
|
|
55
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Build the receiver: GET /info, GET /pull, POST /push.
|
|
58
|
+
*
|
|
59
|
+
* 🔴 There was a fourth route, `GET /requested`, and it is gone (2026-09-15). It
|
|
60
|
+
* served a single integer — "was Sync now pressed here?" — and existed only to be
|
|
61
|
+
* asked, every 20 seconds, by a dialer that otherwise had nothing to say. That made
|
|
62
|
+
* it **68 % of `vault`'s entire traffic**. A receiver with news now says so over
|
|
63
|
+
* {@link import("./signal")} instead of waiting to be asked; deleting the route is
|
|
64
|
+
* what stops a stale consumer from keeping the poll alive against a new build.
|
|
65
|
+
*/
|
|
56
66
|
export function createSyncReceiver(options) {
|
|
57
67
|
const { log, verifyToken } = options;
|
|
58
68
|
if ((options.applyOne === undefined) === (options.beginBatch === undefined)) {
|
|
@@ -84,7 +94,15 @@ export function createSyncReceiver(options) {
|
|
|
84
94
|
};
|
|
85
95
|
return c.json(body, 200, noStore);
|
|
86
96
|
});
|
|
87
|
-
|
|
97
|
+
const signal = options.signal;
|
|
98
|
+
if (signal !== undefined) {
|
|
99
|
+
api.get("/signal", (c) => signalResponse(signal, {
|
|
100
|
+
// The catch-up frame: a client reconnecting after a sleep learns the
|
|
101
|
+
// current head without a round trip of its own.
|
|
102
|
+
initial: { head: log.head(), instanceId: log.instanceId() },
|
|
103
|
+
signal: c.req.raw.signal,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
88
106
|
api.get("/pull", (c) => {
|
|
89
107
|
const afterRaw = c.req.query("after");
|
|
90
108
|
const after = afterRaw !== undefined && /^\d+$/.test(afterRaw) ? Number(afterRaw) : 0;
|
|
@@ -227,17 +245,5 @@ export function createHttpRemote(options) {
|
|
|
227
245
|
throw new Error(`sync/push: ${r.status}`);
|
|
228
246
|
return await readJson(r, "sync/push");
|
|
229
247
|
},
|
|
230
|
-
async requestedAt() {
|
|
231
|
-
try {
|
|
232
|
-
const r = await request("/requested");
|
|
233
|
-
if (!r.ok)
|
|
234
|
-
return null;
|
|
235
|
-
const body = await readJson(r, "sync/requested");
|
|
236
|
-
return typeof body.requestedAt === "number" ? body.requestedAt : null;
|
|
237
|
-
}
|
|
238
|
-
catch {
|
|
239
|
-
return null;
|
|
240
|
-
}
|
|
241
|
-
},
|
|
242
248
|
};
|
|
243
249
|
}
|