orez-sync-cf-host 0.4.52

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/src/host.ts ADDED
@@ -0,0 +1,1264 @@
1
+ import { DurableObject } from 'cloudflare:workers'
2
+
3
+ import { validatePullCaps, validateSyncHostConfig } from './config.js'
4
+ import {
5
+ engine_apply_upstream,
6
+ engine_apply_upstream_snapshot,
7
+ engine_assemble_push_response,
8
+ engine_compile_query,
9
+ engine_finalize,
10
+ engine_handle_pull,
11
+ engine_handle_query_pull,
12
+ engine_init_query_schema,
13
+ engine_init_schema,
14
+ engine_invalidate,
15
+ engine_memory_bytes,
16
+ engine_preflight,
17
+ engine_prune,
18
+ engine_push_validate,
19
+ engine_record_app_error,
20
+ engine_state,
21
+ engine_version,
22
+ initSync,
23
+ } from './generated/sync_wasm.js'
24
+ import wasmModule from './generated/sync_wasm_bg.wasm'
25
+ import {
26
+ SqlStorageDirect,
27
+ SqlStorageMutatorTransaction,
28
+ SqlStorageSyncDb,
29
+ } from './sql-storage-adapter.js'
30
+ import { MutationApplicationError } from './types.js'
31
+
32
+ import type {
33
+ DeferredEffect,
34
+ JsonValue,
35
+ NormalizedClaims,
36
+ PullCaps,
37
+ SyncHostConfig,
38
+ SyncHostEnv,
39
+ } from './types.js'
40
+
41
+ initSync({ module: wasmModule })
42
+
43
+ const CLAIMS_HEADER = 'x-orez-sync-claims'
44
+ const NAMESPACE_HEADER = 'x-orez-sync-namespace'
45
+ const UPSTREAM_PATH_HEADER = 'x-orez-sync-upstream-path'
46
+ const DEFAULT_CAPS: PullCaps = {
47
+ maxChangeRows: 10_000,
48
+ maxChangeBytes: 2_000_000,
49
+ }
50
+
51
+ type PushMutation = {
52
+ id: string
53
+ clientID: string
54
+ name: string
55
+ args: JsonValue[]
56
+ }
57
+
58
+ type PushPlan =
59
+ | { kind: 'respond'; response: unknown }
60
+ | { kind: 'process'; clientGroupID: string; mutations: PushMutation[] }
61
+
62
+ type Preflight = { kind: 'applied' } | { kind: 'replay'; expected: string }
63
+
64
+ type MutationResult = {
65
+ clientID: string
66
+ id: string
67
+ result: Record<string, unknown>
68
+ }
69
+
70
+ type EngineState = { watermark: string; floor: string; upstreamWatermark: string }
71
+ type UpstreamBatch = {
72
+ watermark: number
73
+ changes: Array<{
74
+ watermark: number
75
+ tableName: string
76
+ op: string
77
+ rowData: Record<string, unknown> | null
78
+ oldData: Record<string, unknown> | null
79
+ }>
80
+ }
81
+ type ApplyUpstreamResult = {
82
+ watermark: number | string
83
+ applied: number
84
+ caughtUp: boolean
85
+ }
86
+ type SocketAttachment = { clientID: string }
87
+ type FaultPoint =
88
+ | 'push_before_mutation'
89
+ | 'push_after_write_before_commit'
90
+ | 'push_after_commit_before_response'
91
+ | 'pull_during_tx'
92
+ | 'pull_after_commit'
93
+ type FaultKind = 'error' | 'quota'
94
+
95
+ type Counters = {
96
+ pulls: number
97
+ pushes: number
98
+ resets: number
99
+ applicationErrors: number
100
+ invariantFailures: number
101
+ retentionRuns: number
102
+ queryRecompilations: number
103
+ wasmBoundaryCalls: number
104
+ wakeFrames: number
105
+ wakeBatches: number
106
+ externalEffectFailures: number
107
+ }
108
+
109
+ function freshCounters(): Counters {
110
+ return {
111
+ pulls: 0,
112
+ pushes: 0,
113
+ resets: 0,
114
+ applicationErrors: 0,
115
+ invariantFailures: 0,
116
+ retentionRuns: 0,
117
+ queryRecompilations: 0,
118
+ wasmBoundaryCalls: 0,
119
+ wakeFrames: 0,
120
+ wakeBatches: 0,
121
+ externalEffectFailures: 0,
122
+ }
123
+ }
124
+
125
+ function json(value: unknown, status = 200): Response {
126
+ return Response.json(value, {
127
+ status,
128
+ headers: { 'cache-control': 'no-store' },
129
+ })
130
+ }
131
+
132
+ function statusOf(error: unknown): number {
133
+ if (typeof error === 'object' && error !== null && 'status' in error) {
134
+ const status = Number(error.status)
135
+ if (Number.isInteger(status) && status >= 400 && status <= 599) return status
136
+ }
137
+ return 500
138
+ }
139
+
140
+ function errorMessage(error: unknown): string {
141
+ return error instanceof Error ? error.message : String(error)
142
+ }
143
+
144
+ function requestError(message: string, status = 400): Error & { status: number } {
145
+ return Object.assign(new Error(message), { status })
146
+ }
147
+
148
+ async function requestJson(request: Request): Promise<unknown> {
149
+ try {
150
+ return await request.json()
151
+ } catch {
152
+ throw requestError('invalid JSON request body')
153
+ }
154
+ }
155
+
156
+ async function requestObject(request: Request): Promise<Record<string, unknown>> {
157
+ const value = await requestJson(request)
158
+ if (!value || typeof value !== 'object' || Array.isArray(value))
159
+ throw requestError('request body must be a JSON object')
160
+ return value as Record<string, unknown>
161
+ }
162
+
163
+ function routeAfterNamespace(pathname: string): string {
164
+ const [, , ...parts] = pathname.split('/')
165
+ return `/${parts.join('/')}`
166
+ }
167
+
168
+ function claimsFromRequest(request: Request): NormalizedClaims | null {
169
+ const encoded = request.headers.get(CLAIMS_HEADER)
170
+ if (!encoded) return null
171
+ try {
172
+ const value = JSON.parse(decodeURIComponent(encoded)) as NormalizedClaims
173
+ return value && typeof value.userID === 'string' && value.userID.length > 0
174
+ ? value
175
+ : null
176
+ } catch {
177
+ return null
178
+ }
179
+ }
180
+
181
+ async function namespaceHash(namespace: string): Promise<string> {
182
+ const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(namespace))
183
+ return Array.from(new Uint8Array(bytes).slice(0, 8), (byte) =>
184
+ byte.toString(16).padStart(2, '0')
185
+ ).join('')
186
+ }
187
+
188
+ function socketAttachment(socket: WebSocket): SocketAttachment | null {
189
+ const value = socket.deserializeAttachment() as SocketAttachment | null
190
+ return value && typeof value.clientID === 'string' ? value : null
191
+ }
192
+
193
+ // Closing a socket that is already closed/closing throws, and a throw inside a
194
+ // hibernatable WebSocket handler aborts the DO. Swallow it: the socket is going
195
+ // away regardless.
196
+ function socketCloseQuietly(socket: WebSocket, code: number, reason: string): void {
197
+ try {
198
+ socket.close(code, reason)
199
+ } catch {
200
+ // already closing/closed, or workerd rejected the code — nothing to do
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Create the consumer-facing Worker router. Authentication happens here; the
206
+ * Durable Object receives only normalized claims over a binding-private header.
207
+ */
208
+ export function createSyncWorker<Env extends SyncHostEnv>(
209
+ config: SyncHostConfig<Env>
210
+ ): ExportedHandler<Env> {
211
+ validateSyncHostConfig(config)
212
+ return {
213
+ async fetch(request, env): Promise<Response> {
214
+ const namespace = config.namespace(request)
215
+ if (!namespace) return new Response('orez sync-cf-host', { status: 200 })
216
+
217
+ const route = routeAfterNamespace(new URL(request.url).pathname)
218
+ const isAdmin = route.startsWith('/admin/')
219
+ if (isAdmin) {
220
+ const authorized = config.authorizeAdmin
221
+ ? await config.authorizeAdmin(request, env)
222
+ : Boolean(env.ADMIN_KEY) && request.headers.get('x-admin-key') === env.ADMIN_KEY
223
+ if (!authorized) return json({ error: 'forbidden' }, 403)
224
+ }
225
+
226
+ const headers = new Headers(request.headers)
227
+ headers.delete(CLAIMS_HEADER)
228
+ headers.delete(NAMESPACE_HEADER)
229
+ headers.delete(UPSTREAM_PATH_HEADER)
230
+ // Wake sockets carry only a client ID and the literal "wake" hint; they
231
+ // expose no rows, claims, or mutation state. Keep this advisory channel
232
+ // compatible with the vendored transport, which cannot attach an HTTP
233
+ // Authorization header to its native WebSocket constructor.
234
+ if (!isAdmin && route !== '/wake' && route !== '/notify') {
235
+ const claims = await config.authenticate(request, env)
236
+ if (!claims) return json({ error: 'missing authentication' }, 401)
237
+ headers.set(CLAIMS_HEADER, encodeURIComponent(JSON.stringify(claims)))
238
+ }
239
+ headers.set(NAMESPACE_HEADER, await namespaceHash(namespace))
240
+ if (config.upstream) {
241
+ const namespacePath =
242
+ typeof config.upstream.namespacePath === 'function'
243
+ ? config.upstream.namespacePath(namespace)
244
+ : config.upstream.namespacePath
245
+ if (!namespacePath.startsWith('/')) {
246
+ return json({ error: 'upstream namespacePath must be an absolute path' }, 500)
247
+ }
248
+ headers.set(UPSTREAM_PATH_HEADER, namespacePath.replace(/\/$/, ''))
249
+ }
250
+
251
+ const forwarded = new Request(request, { headers })
252
+ const id = env.SYNC_DO.idFromName(namespace)
253
+ return env.SYNC_DO.get(id).fetch(forwarded)
254
+ },
255
+ }
256
+ }
257
+
258
+ /** Create the namespace Durable Object class for one bundled consumer config. */
259
+ export function createSyncDurableObject<Env extends SyncHostEnv>(
260
+ config: SyncHostConfig<Env>
261
+ ) {
262
+ validateSyncHostConfig(config)
263
+ const defaultRetainChanges = String(config.retainChanges ?? 4_096)
264
+ const caps: PullCaps = validatePullCaps({ ...DEFAULT_CAPS, ...config.caps })
265
+ const idleTeardownMs = config.idleTeardownMs ?? 5_000
266
+ // A CF fan-out wakes every client into an HTTP pull. Give concurrent writer
267
+ // requests a real batching window so a storm burst creates one pull wave.
268
+ const wakeCoalesceMs = config.wakeCoalesceMs ?? 25
269
+ const upstreamIntervalMs = config.upstream?.intervalMs ?? 15_000
270
+ const upstreamLimit = config.upstream?.changeLimit ?? 1_000
271
+
272
+ return class SyncDurableObject extends DurableObject<Env> {
273
+ readonly #engineDb: SqlStorageSyncDb
274
+ readonly #directSql: SqlStorageDirect
275
+ readonly #mutatorSql: SqlStorageMutatorTransaction
276
+ #bootID = crypto.randomUUID()
277
+ #lastRequestAt = 0
278
+ #hibernations = 0
279
+ #dropNextPushResponse = false
280
+ #counters = freshCounters()
281
+ #pulling = new Set<string>()
282
+ #wakeOrigins = new Set<string>()
283
+ #wakeRecipients = new Set<WebSocket>()
284
+ #wakePromise: Promise<void> | null = null
285
+ #ingestPromise: Promise<number> | null = null
286
+
287
+ constructor(ctx: DurableObjectState, env: Env) {
288
+ super(ctx, env)
289
+ this.#engineDb = new SqlStorageSyncDb(ctx.storage.sql)
290
+ this.#directSql = new SqlStorageDirect(ctx.storage.sql)
291
+ this.#mutatorSql = new SqlStorageMutatorTransaction(this.#directSql, (ast) =>
292
+ this.#wasm(() => engine_compile_query(config.schema, ast))
293
+ )
294
+ ctx.blockConcurrencyWhile(async () => {
295
+ ctx.storage.transactionSync(() => {
296
+ config.initialize(this.#directSql)
297
+ this.#directSql.exec(`CREATE TABLE IF NOT EXISTS _zsync_host_control (
298
+ key TEXT PRIMARY KEY,
299
+ value TEXT NOT NULL
300
+ )`)
301
+ this.#directSql.exec(
302
+ "INSERT OR IGNORE INTO _zsync_host_control (key, value) VALUES ('writerEnabled', '1')"
303
+ )
304
+ this.#wasm(() => engine_init_schema(this.#engineDb, config.schema))
305
+ if (config.queryAware || config.resolveQuery)
306
+ this.#wasm(() => engine_init_query_schema(this.#engineDb))
307
+ })
308
+ if (config.upstream && (await ctx.storage.getAlarm()) === null) {
309
+ await ctx.storage.setAlarm(Date.now() + upstreamIntervalMs)
310
+ }
311
+ })
312
+ }
313
+
314
+ #wasm<T>(call: () => T): T {
315
+ this.#counters.wasmBoundaryCalls++
316
+ return call()
317
+ }
318
+
319
+ #simulateIdleTeardown(now: number): void {
320
+ if (this.#lastRequestAt > 0 && now - this.#lastRequestAt >= idleTeardownMs) {
321
+ this.#bootID = crypto.randomUUID()
322
+ this.#hibernations++
323
+ this.#counters = freshCounters()
324
+ this.#pulling.clear()
325
+ this.#wakeOrigins.clear()
326
+ this.#wakeRecipients.clear()
327
+ this.#wakePromise = null
328
+ }
329
+ this.#lastRequestAt = now
330
+ }
331
+
332
+ #visibility(claims: NormalizedClaims): unknown {
333
+ if (!config.visibility || !this.#visibilityEnabled()) return null
334
+ return {
335
+ rowLocal:
336
+ typeof config.visibility.rowLocal === 'function'
337
+ ? config.visibility.rowLocal(claims)
338
+ : config.visibility.rowLocal,
339
+ filters: Object.keys(config.schema.tables).flatMap((table) => {
340
+ const filter = config.visibility?.filter(table, claims)
341
+ return filter
342
+ ? [{ table, sql: filter.sql, params: [...(filter.params ?? [])] }]
343
+ : []
344
+ }),
345
+ }
346
+ }
347
+
348
+ // the admin-set namespace knobs (writer, visibility, query-aware,
349
+ // retention) live in _zsync_host_control, NOT in instance fields: a real
350
+ // eviction recreates this instance, and an in-memory override silently
351
+ // reverting to the config default mid-run turns a query-aware namespace
352
+ // back into a baseline one — the client keeps re-sending its desired-query
353
+ // patch, the host ignores it, and every pull answers {unchanged:true}
354
+ // forever.
355
+ #controlGet(key: string): string | null {
356
+ const row = this.#directSql.query<{ value: string }>(
357
+ 'SELECT value FROM _zsync_host_control WHERE key = ?',
358
+ [key]
359
+ )[0]
360
+ return row?.value ?? null
361
+ }
362
+
363
+ #controlSet(key: string, value: string): void {
364
+ this.#directSql.exec(
365
+ 'INSERT INTO _zsync_host_control (key, value) VALUES (?, ?) ' +
366
+ 'ON CONFLICT(key) DO UPDATE SET value = excluded.value',
367
+ [key, value]
368
+ )
369
+ }
370
+
371
+ #writerEnabled(): boolean {
372
+ return this.#controlGet('writerEnabled') === '1'
373
+ }
374
+
375
+ #visibilityEnabled(): boolean {
376
+ const value = this.#controlGet('visibilityEnabled')
377
+ return value === null ? (config.visibilityEnabled ?? false) : value === '1'
378
+ }
379
+
380
+ #queryAwareOverride(): boolean | null {
381
+ const value = this.#controlGet('queryAwareOverride')
382
+ return value === null ? null : value === '1'
383
+ }
384
+
385
+ #retainChanges(): string {
386
+ return this.#controlGet('retainChanges') ?? defaultRetainChanges
387
+ }
388
+
389
+ #takeFault(point: FaultPoint): FaultKind | null {
390
+ if (this.#controlGet('faultPoint') !== point) return null
391
+ const kind = this.#controlGet('faultKind')
392
+ this.#directSql.exec(
393
+ "DELETE FROM _zsync_host_control WHERE key IN ('faultPoint', 'faultKind')"
394
+ )
395
+ return kind === 'quota' ? 'quota' : 'error'
396
+ }
397
+
398
+ #faultError(kind: FaultKind, point: FaultPoint): Error & { status: number } {
399
+ return requestError(
400
+ `injected ${kind} fault at ${point}`,
401
+ kind === 'quota' ? 507 : 500
402
+ )
403
+ }
404
+
405
+ #engineState(): EngineState | null {
406
+ try {
407
+ return this.#wasm(() => engine_state(this.#engineDb)) as EngineState
408
+ } catch {
409
+ return null
410
+ }
411
+ }
412
+
413
+ #serviceBinding(name = config.upstream?.binding): {
414
+ fetch(input: string | Request, init?: RequestInit): Promise<Response>
415
+ } {
416
+ const value = name
417
+ ? (this.env as unknown as Record<string, unknown>)[name]
418
+ : undefined
419
+ if (!value || typeof (value as { fetch?: unknown }).fetch !== 'function') {
420
+ throw requestError(
421
+ `missing upstream service binding: ${name ?? '(not configured)'}`,
422
+ 500
423
+ )
424
+ }
425
+ return value as {
426
+ fetch(input: string | Request, init?: RequestInit): Promise<Response>
427
+ }
428
+ }
429
+
430
+ #rememberUpstreamPath(request: Request): string | null {
431
+ if (!config.upstream) return null
432
+ const path = request.headers.get(UPSTREAM_PATH_HEADER)
433
+ if (path) {
434
+ this.#controlSet('upstreamPath', path)
435
+ return path
436
+ }
437
+ return this.#controlGet('upstreamPath')
438
+ }
439
+
440
+ #ingest(upstreamPath?: string | null): Promise<number> {
441
+ if (!config.upstream) return Promise.resolve(0)
442
+ if (this.#ingestPromise) return this.#ingestPromise
443
+ const path = upstreamPath ?? this.#controlGet('upstreamPath')
444
+ if (!path) return Promise.resolve(0)
445
+ this.#ingestPromise = (async () => {
446
+ let total = 0
447
+ for (;;) {
448
+ const cursor = this.#engineState()?.upstreamWatermark ?? '0'
449
+ const endpoint = new URL(`${path}/changes`, 'https://upstream.invalid')
450
+ endpoint.searchParams.set('watermark', cursor)
451
+ endpoint.searchParams.set('limit', String(upstreamLimit))
452
+ const response = await this.#serviceBinding().fetch(endpoint.toString(), {
453
+ headers: { host: endpoint.host },
454
+ })
455
+ if (response.status === 410) {
456
+ const snapshotEndpoint = new URL(`${path}/snapshot`, endpoint)
457
+ const snapshotResponse = await this.#serviceBinding().fetch(
458
+ snapshotEndpoint.toString(),
459
+ { headers: { host: snapshotEndpoint.host } }
460
+ )
461
+ if (!snapshotResponse.ok) {
462
+ throw new Error(`upstream snapshot returned ${snapshotResponse.status}`)
463
+ }
464
+ const snapshot = await snapshotResponse.json()
465
+ const rebuilt = this.ctx.storage.transactionSync(() =>
466
+ this.#wasm(() =>
467
+ engine_apply_upstream_snapshot(this.#engineDb, config.schema, snapshot)
468
+ )
469
+ ) as ApplyUpstreamResult
470
+ total += rebuilt.applied
471
+ continue
472
+ }
473
+ if (!response.ok) {
474
+ throw new Error(`upstream changes returned ${response.status}`)
475
+ }
476
+ const batch = (await response.json()) as UpstreamBatch
477
+ if (!Number.isSafeInteger(batch.watermark) || !Array.isArray(batch.changes)) {
478
+ throw new Error('invalid upstream changes response')
479
+ }
480
+ const result = this.ctx.storage.transactionSync(() =>
481
+ this.#wasm(() => engine_apply_upstream(this.#engineDb, config.schema, batch))
482
+ ) as ApplyUpstreamResult
483
+ total += result.applied
484
+ if (result.caughtUp) break
485
+ if (result.applied === 0) {
486
+ throw new Error('upstream change cursor made no progress')
487
+ }
488
+ }
489
+ if (total > 0) await this.#enqueueWake('__upstream__')
490
+ return total
491
+ })().finally(() => {
492
+ this.#ingestPromise = null
493
+ })
494
+ return this.#ingestPromise
495
+ }
496
+
497
+ #log(fields: Record<string, unknown>): void {
498
+ console.log(
499
+ JSON.stringify({
500
+ event: 'sync_request',
501
+ hostVersion: config.hostVersion,
502
+ engineVersion: engine_version(),
503
+ ...fields,
504
+ })
505
+ )
506
+ }
507
+
508
+ async #runEffects(effects: DeferredEffect[]): Promise<void> {
509
+ for (const effect of effects) {
510
+ try {
511
+ await effect()
512
+ } catch (error) {
513
+ this.#counters.externalEffectFailures++
514
+ console.error(
515
+ JSON.stringify({
516
+ event: 'sync_external_effect_error',
517
+ hostVersion: config.hostVersion,
518
+ error: errorMessage(error),
519
+ })
520
+ )
521
+ }
522
+ }
523
+ }
524
+
525
+ #enqueueWake(originClientID: string): Promise<void> {
526
+ this.#wakeOrigins.add(originClientID)
527
+ for (const socket of this.ctx.getWebSockets()) {
528
+ const attachment = socketAttachment(socket)
529
+ if (
530
+ !attachment ||
531
+ attachment.clientID === originClientID ||
532
+ this.#pulling.has(attachment.clientID)
533
+ ) {
534
+ continue
535
+ }
536
+ this.#wakeRecipients.add(socket)
537
+ }
538
+ return this.#scheduleWake()
539
+ }
540
+
541
+ #scheduleWake(): Promise<void> {
542
+ if (!this.#wakePromise) {
543
+ const queuedAt = performance.now()
544
+ this.#wakePromise = (async () => {
545
+ await scheduler.wait(wakeCoalesceMs)
546
+ const fanoutStarted = performance.now()
547
+ const origins = this.#wakeOrigins
548
+ this.#wakeOrigins = new Set()
549
+ const recipients = this.#wakeRecipients
550
+ this.#wakeRecipients = new Set()
551
+ this.#counters.wakeBatches++
552
+ let sent = 0
553
+ const sockets = this.ctx.getWebSockets()
554
+ for (const socket of recipients) {
555
+ try {
556
+ socket.send('wake')
557
+ sent++
558
+ this.#counters.wakeFrames++
559
+ } catch {
560
+ // A closing hibernating socket disappears from getWebSockets;
561
+ // a race here is advisory and carries no correctness weight.
562
+ }
563
+ }
564
+ console.log(
565
+ JSON.stringify({
566
+ event: 'sync_wake',
567
+ hostVersion: config.hostVersion,
568
+ socketCount: sockets.length,
569
+ originCount: origins.size,
570
+ sent,
571
+ eligibleRecipients: recipients.size,
572
+ coalesceMs: fanoutStarted - queuedAt,
573
+ fanoutMs: performance.now() - fanoutStarted,
574
+ })
575
+ )
576
+ })().finally(() => {
577
+ this.#wakePromise = null
578
+ if (this.#wakeOrigins.size > 0) void this.#scheduleWake()
579
+ })
580
+ }
581
+ return this.#wakePromise
582
+ }
583
+
584
+ async #pull(
585
+ request: Request,
586
+ claims: NormalizedClaims,
587
+ namespace: string
588
+ ): Promise<Response> {
589
+ this.#counters.pulls++
590
+ this.#engineDb.resetStats()
591
+ const started = performance.now()
592
+ let transactionMs = 0
593
+ let body: Record<string, unknown> | undefined
594
+ try {
595
+ body = await requestObject(request)
596
+ const queryAware =
597
+ this.#queryAwareOverride() ??
598
+ (typeof config.queryAware === 'function'
599
+ ? config.queryAware(claims)
600
+ : (config.queryAware ?? Boolean(config.resolveQuery)))
601
+ if (queryAware && body.queries) {
602
+ const queries = body.queries as {
603
+ version?: unknown
604
+ patch?: unknown
605
+ }
606
+ if (Array.isArray(queries.patch)) {
607
+ const patch = []
608
+ for (const operation of queries.patch) {
609
+ if (!operation || typeof operation !== 'object') {
610
+ patch.push(operation)
611
+ continue
612
+ }
613
+ const op = operation as Record<string, unknown>
614
+ if (op.op === 'put') {
615
+ if (!config.resolveQuery || typeof op.name !== 'string') {
616
+ throw requestError('query put requires a server-resolved named query')
617
+ }
618
+ if (!Array.isArray(op.args)) {
619
+ throw requestError('named query args must be an array')
620
+ }
621
+ const args = op.args as JsonValue[]
622
+ let ast: JsonValue
623
+ try {
624
+ // resolveQuery may be async and needs `env` (a consumer can
625
+ // delegate the transform to its app's real synced-queries
626
+ // endpoint over an app service binding — authenticate runs in the
627
+ // worker isolate, but the query loop runs here in the DO, so the
628
+ // binding must come from the DO's own env, not a shared global).
629
+ ast = await config.resolveQuery(op.name, args, claims, this.env)
630
+ } catch (error) {
631
+ throw requestError(`unknown or unsupported named query: ${op.name}`)
632
+ }
633
+ const transformVersion =
634
+ typeof config.queryTransformVersion === 'function'
635
+ ? config.queryTransformVersion(claims)
636
+ : (config.queryTransformVersion ?? 0)
637
+ if (!Number.isSafeInteger(transformVersion) || transformVersion < 0) {
638
+ throw new TypeError(
639
+ 'queryTransformVersion must be a non-negative safe integer'
640
+ )
641
+ }
642
+ patch.push({
643
+ op: 'put',
644
+ hash: op.hash,
645
+ ast,
646
+ transformVersion,
647
+ })
648
+ } else patch.push(operation)
649
+ }
650
+ body = { ...body, queries: { ...queries, patch } }
651
+ }
652
+ }
653
+ const clientID = typeof body.clientID === 'string' ? body.clientID : ''
654
+ this.#pulling.add(clientID)
655
+ let response: Record<string, unknown>
656
+ try {
657
+ const txStarted = performance.now()
658
+ const duringFault = this.#takeFault('pull_during_tx')
659
+ response = this.ctx.storage.transactionSync(() => {
660
+ const result = this.#wasm(() =>
661
+ queryAware
662
+ ? engine_handle_query_pull(
663
+ this.#engineDb,
664
+ config.schema,
665
+ this.#retainChanges(),
666
+ body,
667
+ claims.userID
668
+ )
669
+ : engine_handle_pull(
670
+ this.#engineDb,
671
+ config.schema,
672
+ this.#visibility(claims),
673
+ caps,
674
+ this.#retainChanges(),
675
+ body,
676
+ claims.userID
677
+ )
678
+ ) as Record<string, unknown>
679
+ if (duringFault) throw this.#faultError(duringFault, 'pull_during_tx')
680
+ return result
681
+ })
682
+ transactionMs = performance.now() - txStarted
683
+ } finally {
684
+ this.#pulling.delete(clientID)
685
+ }
686
+ const afterPullFault = this.#takeFault('pull_after_commit')
687
+ if (afterPullFault) throw this.#faultError(afterPullFault, 'pull_after_commit')
688
+ const patch = Array.isArray(response.rowsPatch) ? response.rowsPatch : []
689
+ const queriesBody = body.queries as { patch?: unknown[] } | undefined
690
+ const queryPuts =
691
+ queryAware && Array.isArray(queriesBody?.patch)
692
+ ? queriesBody.patch.filter(
693
+ (entry) => (entry as { op?: unknown } | null)?.op === 'put'
694
+ ).length
695
+ : 0
696
+ this.#counters.queryRecompilations += queryPuts
697
+ const state = this.#engineState()
698
+ this.#log({
699
+ namespaceHash: namespace,
700
+ requestKind: 'pull',
701
+ resultClass: response.unchanged === true ? 'unchanged' : 'success',
702
+ inputCookie: body.cookie ?? null,
703
+ outputCookie: response.cookie ?? null,
704
+ retainedFloor: state?.floor ?? null,
705
+ currentWatermark: state?.watermark ?? null,
706
+ changeRowsScanned: null,
707
+ changeRowsIncluded: null,
708
+ queriesRecomputed: queryPuts,
709
+ rowPuts: patch.filter((entry) => entry?.op === 'put').length,
710
+ rowDeletes: patch.filter((entry) => entry?.op === 'del').length,
711
+ lmidAdvances: 0,
712
+ transactionMs,
713
+ totalMs: performance.now() - started,
714
+ resetReason: null,
715
+ wasmBoundaryCalls: this.#counters.wasmBoundaryCalls,
716
+ sql: this.#engineDb.stats,
717
+ })
718
+ return json(response)
719
+ } catch (error) {
720
+ const status = statusOf(error)
721
+ if (status === 409) this.#counters.resets++
722
+ if (status === 500) this.#counters.invariantFailures++
723
+ this.#log({
724
+ namespaceHash: namespace,
725
+ requestKind: 'pull',
726
+ resultClass: status === 409 ? 'reset' : 'error',
727
+ inputCookie: body?.cookie ?? null,
728
+ outputCookie: null,
729
+ retainedFloor: this.#engineState()?.floor ?? null,
730
+ currentWatermark: this.#engineState()?.watermark ?? null,
731
+ changeRowsScanned: null,
732
+ changeRowsIncluded: 0,
733
+ queriesRecomputed: 0,
734
+ rowPuts: 0,
735
+ rowDeletes: 0,
736
+ lmidAdvances: 0,
737
+ transactionMs,
738
+ totalMs: performance.now() - started,
739
+ resetReason: status === 409 ? errorMessage(error) : null,
740
+ })
741
+ return json({ error: errorMessage(error) }, status)
742
+ }
743
+ }
744
+
745
+ async #push(
746
+ request: Request,
747
+ claims: NormalizedClaims,
748
+ namespace: string,
749
+ upstreamPath: string | null
750
+ ): Promise<Response> {
751
+ this.#counters.pushes++
752
+ this.#engineDb.resetStats()
753
+ const started = performance.now()
754
+ let transactionMs = 0
755
+ let lmidAdvances = 0
756
+ let resultClass = 'success'
757
+ if (!this.#writerEnabled()) {
758
+ // Workerd requires the request stream to be consumed before the DO
759
+ // returns a response. Discard it without parsing or logging payloads.
760
+ await request.arrayBuffer()
761
+ const state = this.#engineState()
762
+ this.#log({
763
+ namespaceHash: namespace,
764
+ requestKind: 'push',
765
+ resultClass: 'writer_disabled',
766
+ inputCookie: null,
767
+ outputCookie: null,
768
+ retainedFloor: state?.floor ?? null,
769
+ currentWatermark: state?.watermark ?? null,
770
+ changeRowsScanned: 0,
771
+ changeRowsIncluded: 0,
772
+ queriesRecomputed: 0,
773
+ rowPuts: 0,
774
+ rowDeletes: 0,
775
+ lmidAdvances: 0,
776
+ transactionMs: 0,
777
+ totalMs: performance.now() - started,
778
+ resetReason: 'writer disabled by operator',
779
+ wasmBoundaryCalls: this.#counters.wasmBoundaryCalls,
780
+ sql: this.#engineDb.stats,
781
+ })
782
+ return json({ error: 'writer disabled by operator' }, 503)
783
+ }
784
+ if (config.mutateUrl) {
785
+ try {
786
+ const bytes = await request.arrayBuffer()
787
+ const body = JSON.parse(new TextDecoder().decode(bytes)) as Record<
788
+ string,
789
+ unknown
790
+ >
791
+ const plan = this.#wasm(() => engine_push_validate(body)) as PushPlan
792
+ if (plan.kind === 'respond') return json(plan.response)
793
+
794
+ const endpoint = new URL(
795
+ `${upstreamPath ?? ''}${config.mutateUrl}`,
796
+ 'https://upstream.invalid'
797
+ )
798
+ const headers = new Headers(request.headers)
799
+ headers.delete(CLAIMS_HEADER)
800
+ headers.delete(NAMESPACE_HEADER)
801
+ headers.delete(UPSTREAM_PATH_HEADER)
802
+ headers.set('host', endpoint.host)
803
+ const upstreamResponse = await this.#serviceBinding(
804
+ config.mutateBinding ?? config.upstream?.binding
805
+ ).fetch(endpoint.toString(), {
806
+ method: 'POST',
807
+ headers,
808
+ body: bytes,
809
+ })
810
+ if (!upstreamResponse.ok) {
811
+ return new Response(upstreamResponse.body, upstreamResponse)
812
+ }
813
+ const upstreamBody = (await upstreamResponse.json()) as {
814
+ mutations?: Array<{ id?: { clientID?: unknown; id?: unknown } }>
815
+ pushResponse?: {
816
+ mutations?: Array<{ id?: { clientID?: unknown; id?: unknown } }>
817
+ }
818
+ }
819
+ const acknowledged =
820
+ upstreamBody.pushResponse?.mutations ?? upstreamBody.mutations
821
+ if (!Array.isArray(acknowledged)) {
822
+ throw new Error('delegated push returned no mutation results')
823
+ }
824
+ for (const mutation of plan.mutations) {
825
+ const ack = acknowledged.some(
826
+ (result) =>
827
+ result.id?.clientID === mutation.clientID &&
828
+ String(result.id?.id) === mutation.id
829
+ )
830
+ if (!ack) continue
831
+ this.ctx.storage.transactionSync(() => {
832
+ const decision = this.#wasm(() =>
833
+ engine_preflight(
834
+ this.#engineDb,
835
+ plan.clientGroupID,
836
+ mutation.clientID,
837
+ mutation.id,
838
+ claims.userID
839
+ )
840
+ ) as Preflight
841
+ if (decision.kind === 'applied') {
842
+ this.#wasm(() =>
843
+ engine_finalize(
844
+ this.#engineDb,
845
+ plan.clientGroupID,
846
+ mutation.clientID,
847
+ mutation.id
848
+ )
849
+ )
850
+ lmidAdvances++
851
+ }
852
+ })
853
+ }
854
+ if (lmidAdvances > 0) {
855
+ this.ctx.storage.transactionSync(() =>
856
+ this.#wasm(() => engine_prune(this.#engineDb, this.#retainChanges()))
857
+ )
858
+ }
859
+ await this.#ingest(upstreamPath)
860
+ return json({ pushResponse: upstreamBody.pushResponse ?? upstreamBody })
861
+ } catch (error) {
862
+ const status = statusOf(error)
863
+ return json({ error: errorMessage(error) }, status)
864
+ }
865
+ }
866
+ try {
867
+ const body = await requestObject(request)
868
+ const beforeMutationFault = this.#takeFault('push_before_mutation')
869
+ if (beforeMutationFault)
870
+ throw this.#faultError(beforeMutationFault, 'push_before_mutation')
871
+ const plan = this.#wasm(() => engine_push_validate(body)) as PushPlan
872
+ if (plan.kind === 'respond') return json(plan.response)
873
+
874
+ const results: MutationResult[] = []
875
+ for (const mutation of plan.mutations) {
876
+ const deferred: DeferredEffect[] = []
877
+ try {
878
+ const txStarted = performance.now()
879
+ // consume the before-commit fault OUTSIDE the transaction it is
880
+ // about to abort: taken inside, the control-table delete rolls
881
+ // back with the abort and the fault re-fires on every retry
882
+ // instead of being one-shot.
883
+ const beforeCommitFault = this.#takeFault('push_after_write_before_commit')
884
+ const preflight = await this.ctx.storage.transaction(async () => {
885
+ // Storage transactions may retry their closure. Never carry a
886
+ // deferred effect from an abandoned attempt into the commit.
887
+ deferred.length = 0
888
+ const decision = this.#wasm(() =>
889
+ engine_preflight(
890
+ this.#engineDb,
891
+ plan.clientGroupID,
892
+ mutation.clientID,
893
+ mutation.id,
894
+ claims.userID
895
+ )
896
+ ) as Preflight
897
+ if (decision.kind === 'replay') return decision
898
+ const mutator = config.mutators?.[mutation.name]
899
+ if (!mutator) throw new Error(`unknown mutator: ${mutation.name}`)
900
+ await mutator(this.#mutatorSql, mutation.args[0] ?? null, {
901
+ claims,
902
+ clientID: mutation.clientID,
903
+ mutationID: mutation.id,
904
+ defer(effect) {
905
+ deferred.push(effect)
906
+ },
907
+ })
908
+ if (beforeCommitFault)
909
+ throw this.#faultError(
910
+ beforeCommitFault,
911
+ 'push_after_write_before_commit'
912
+ )
913
+ this.#wasm(() =>
914
+ engine_finalize(
915
+ this.#engineDb,
916
+ plan.clientGroupID,
917
+ mutation.clientID,
918
+ mutation.id
919
+ )
920
+ )
921
+ return decision
922
+ })
923
+ transactionMs += performance.now() - txStarted
924
+
925
+ if ((preflight as Preflight).kind === 'replay') {
926
+ const expected = (preflight as Extract<Preflight, { kind: 'replay' }>)
927
+ .expected
928
+ results.push({
929
+ clientID: mutation.clientID,
930
+ id: mutation.id,
931
+ result: {
932
+ error: 'alreadyProcessed',
933
+ details: `Ignoring mutation from ${mutation.clientID} with ID ${mutation.id} as it was already processed. Expected: ${expected}`,
934
+ },
935
+ })
936
+ continue
937
+ }
938
+
939
+ lmidAdvances++
940
+ results.push({
941
+ clientID: mutation.clientID,
942
+ id: mutation.id,
943
+ result: {},
944
+ })
945
+ // Keep the advisory fan-out off the push response's critical path.
946
+ // waitUntil anchors the coalescing timer across request completion,
947
+ // while the next serialized client push can join the same batch.
948
+ this.ctx.waitUntil(this.#enqueueWake(mutation.clientID))
949
+ await this.#runEffects(deferred)
950
+ } catch (error) {
951
+ const isAppError = error instanceof MutationApplicationError
952
+ if (!isAppError) throw error
953
+ this.#counters.applicationErrors++
954
+ resultClass = 'application_error'
955
+ const appError = error as Error & { details?: string }
956
+ const txStarted = performance.now()
957
+ await this.ctx.storage.transaction(async () => {
958
+ this.#wasm(() =>
959
+ engine_record_app_error(
960
+ this.#engineDb,
961
+ plan.clientGroupID,
962
+ mutation.clientID,
963
+ mutation.id,
964
+ claims.userID
965
+ )
966
+ )
967
+ })
968
+ transactionMs += performance.now() - txStarted
969
+ lmidAdvances++
970
+ results.push({
971
+ clientID: mutation.clientID,
972
+ id: mutation.id,
973
+ result: {
974
+ error: 'app',
975
+ message: appError.message,
976
+ details: appError.details ?? appError.message,
977
+ },
978
+ })
979
+ this.ctx.waitUntil(this.#enqueueWake(mutation.clientID))
980
+ }
981
+ }
982
+
983
+ if (plan.mutations.length > 0) {
984
+ const txStarted = performance.now()
985
+ await this.ctx.storage.transaction(async () => {
986
+ this.#wasm(() => engine_prune(this.#engineDb, this.#retainChanges()))
987
+ })
988
+ transactionMs += performance.now() - txStarted
989
+ this.#counters.retentionRuns++
990
+ }
991
+
992
+ const afterCommitFault = this.#takeFault('push_after_commit_before_response')
993
+ if (afterCommitFault)
994
+ throw this.#faultError(afterCommitFault, 'push_after_commit_before_response')
995
+
996
+ const response = this.#wasm(() => engine_assemble_push_response(results))
997
+ const state = this.#engineState()
998
+ this.#log({
999
+ namespaceHash: namespace,
1000
+ requestKind: 'push',
1001
+ resultClass,
1002
+ inputCookie: null,
1003
+ outputCookie: null,
1004
+ retainedFloor: state?.floor ?? null,
1005
+ currentWatermark: state?.watermark ?? null,
1006
+ changeRowsScanned: 0,
1007
+ changeRowsIncluded: 0,
1008
+ queriesRecomputed: 0,
1009
+ rowPuts: 0,
1010
+ rowDeletes: 0,
1011
+ lmidAdvances,
1012
+ transactionMs,
1013
+ totalMs: performance.now() - started,
1014
+ resetReason: null,
1015
+ wasmBoundaryCalls: this.#counters.wasmBoundaryCalls,
1016
+ sql: this.#engineDb.stats,
1017
+ })
1018
+ if (this.#dropNextPushResponse) {
1019
+ this.#dropNextPushResponse = false
1020
+ return json({ error: 'intentionally dropped push response' }, 503)
1021
+ }
1022
+ return json(response)
1023
+ } catch (error) {
1024
+ const status = statusOf(error)
1025
+ if (status === 500) this.#counters.invariantFailures++
1026
+ this.#log({
1027
+ namespaceHash: namespace,
1028
+ requestKind: 'push',
1029
+ resultClass: 'error',
1030
+ inputCookie: null,
1031
+ outputCookie: null,
1032
+ retainedFloor: this.#engineState()?.floor ?? null,
1033
+ currentWatermark: this.#engineState()?.watermark ?? null,
1034
+ changeRowsScanned: 0,
1035
+ changeRowsIncluded: 0,
1036
+ queriesRecomputed: 0,
1037
+ rowPuts: 0,
1038
+ rowDeletes: 0,
1039
+ lmidAdvances,
1040
+ transactionMs,
1041
+ totalMs: performance.now() - started,
1042
+ resetReason: null,
1043
+ })
1044
+ return json({ error: errorMessage(error) }, status)
1045
+ }
1046
+ }
1047
+
1048
+ #wake(request: Request): Response {
1049
+ if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
1050
+ return json({ error: 'websocket upgrade required' }, 426)
1051
+ }
1052
+ const clientID = new URL(request.url).searchParams.get('clientID')
1053
+ if (!clientID) return json({ error: 'clientID is required' }, 400)
1054
+ const pair = new WebSocketPair()
1055
+ const [client, server] = Object.values(pair)
1056
+ server.serializeAttachment({ clientID } satisfies SocketAttachment)
1057
+ this.ctx.acceptWebSocket(server, [`client:${clientID}`])
1058
+ return new Response(null, { status: 101, webSocket: client })
1059
+ }
1060
+
1061
+ #admin(route: string, request: Request): Promise<Response> | Response {
1062
+ if (route === '/admin/health') return json({ ok: true })
1063
+ if (route === '/admin/status') {
1064
+ const heap = (
1065
+ performance as Performance & {
1066
+ memory?: {
1067
+ usedJSHeapSize: number
1068
+ totalJSHeapSize: number
1069
+ jsHeapSizeLimit: number
1070
+ }
1071
+ }
1072
+ ).memory
1073
+ return json({
1074
+ bootID: this.#bootID,
1075
+ idleTeardownMs,
1076
+ hibernations: this.#hibernations,
1077
+ databaseSizeBytes: this.ctx.storage.sql.databaseSize,
1078
+ connectedWakeSockets: this.ctx.getWebSockets().length,
1079
+ writerEnabled: this.#writerEnabled(),
1080
+ wasmMemoryBytes: engine_memory_bytes(),
1081
+ heapUsedBytes: heap?.usedJSHeapSize ?? null,
1082
+ heapTotalBytes: heap?.totalJSHeapSize ?? null,
1083
+ heapLimitBytes: heap?.jsHeapSizeLimit ?? null,
1084
+ engine: this.#engineState(),
1085
+ counters: this.#counters,
1086
+ })
1087
+ }
1088
+ if (route === '/admin/sql') {
1089
+ return request.json().then((body) => {
1090
+ const { query } = body as { query?: string }
1091
+ if (typeof query !== 'string') return json({ error: 'query is required' }, 400)
1092
+ return json({ rows: this.#directSql.query(query) })
1093
+ })
1094
+ }
1095
+ if (route === '/admin/invalidate') {
1096
+ this.ctx.storage.transactionSync(() =>
1097
+ this.#wasm(() => engine_invalidate(this.#engineDb))
1098
+ )
1099
+ return json({ ok: true, engine: this.#engineState() })
1100
+ }
1101
+ if (route === '/admin/drop-next-push-response') {
1102
+ this.#dropNextPushResponse = true
1103
+ return json({ ok: true })
1104
+ }
1105
+ if (route === '/admin/restart') {
1106
+ this.#bootID = crypto.randomUUID()
1107
+ this.#hibernations++
1108
+ this.#counters = freshCounters()
1109
+ this.#pulling.clear()
1110
+ this.#wakeOrigins.clear()
1111
+ this.#wakeRecipients.clear()
1112
+ return json({ ok: true, bootID: this.#bootID })
1113
+ }
1114
+ if (route === '/admin/visibility') {
1115
+ return request.json().then((body) => {
1116
+ const enabled = Boolean((body as { enabled?: unknown }).enabled)
1117
+ this.#controlSet('visibilityEnabled', enabled ? '1' : '0')
1118
+ return json({ ok: true, enabled })
1119
+ })
1120
+ }
1121
+ if (route === '/admin/query-aware') {
1122
+ return request.json().then((body) => {
1123
+ const enabled = Boolean((body as { enabled?: unknown }).enabled)
1124
+ this.#controlSet('queryAwareOverride', enabled ? '1' : '0')
1125
+ return json({ ok: true, enabled })
1126
+ })
1127
+ }
1128
+ if (route === '/admin/retention') {
1129
+ return request
1130
+ .json()
1131
+ .catch(() => ({}))
1132
+ .then((body) => {
1133
+ const value = Number((body as { retainChanges?: unknown }).retainChanges)
1134
+ if (!Number.isSafeInteger(value) || value < 0)
1135
+ return json({ error: 'invalid retainChanges' }, 400)
1136
+ this.#controlSet('retainChanges', String(value))
1137
+ return json({ ok: true, retainChanges: value })
1138
+ })
1139
+ }
1140
+ if (route === '/admin/writer') {
1141
+ if (request.method === 'GET')
1142
+ return json({ writerEnabled: this.#writerEnabled() })
1143
+ return request
1144
+ .json()
1145
+ .catch(() => ({}))
1146
+ .then((body) => {
1147
+ const enabled = (body as { enabled?: unknown }).enabled
1148
+ if (typeof enabled !== 'boolean')
1149
+ return json({ error: 'enabled must be a boolean' }, 400)
1150
+ this.#controlSet('writerEnabled', enabled ? '1' : '0')
1151
+ return json({ ok: true, writerEnabled: enabled })
1152
+ })
1153
+ }
1154
+ if (route === '/admin/fault') {
1155
+ return request
1156
+ .json()
1157
+ .catch(() => ({}))
1158
+ .then((body) => {
1159
+ const value = body as {
1160
+ clear?: unknown
1161
+ point?: unknown
1162
+ kind?: unknown
1163
+ }
1164
+ if (value.clear === true) {
1165
+ this.#directSql.exec(
1166
+ "DELETE FROM _zsync_host_control WHERE key IN ('faultPoint', 'faultKind')"
1167
+ )
1168
+ return json({ ok: true, armed: null })
1169
+ }
1170
+ const points: FaultPoint[] = [
1171
+ 'push_before_mutation',
1172
+ 'push_after_write_before_commit',
1173
+ 'push_after_commit_before_response',
1174
+ 'pull_during_tx',
1175
+ 'pull_after_commit',
1176
+ ]
1177
+ if (!points.includes(value.point as FaultPoint))
1178
+ return json({ error: 'invalid fault point' }, 400)
1179
+ if (value.kind !== 'error' && value.kind !== 'quota')
1180
+ return json({ error: 'invalid fault kind' }, 400)
1181
+ this.#controlSet('faultPoint', value.point as string)
1182
+ this.#controlSet('faultKind', value.kind)
1183
+ return json({
1184
+ ok: true,
1185
+ armed: { point: value.point, kind: value.kind },
1186
+ })
1187
+ })
1188
+ }
1189
+ return json({ error: 'not found' }, 404)
1190
+ }
1191
+
1192
+ async fetch(request: Request): Promise<Response> {
1193
+ this.#simulateIdleTeardown(Date.now())
1194
+ const route = routeAfterNamespace(new URL(request.url).pathname)
1195
+ const namespace = request.headers.get(NAMESPACE_HEADER) ?? 'unknown'
1196
+ const upstreamPath = this.#rememberUpstreamPath(request)
1197
+ if (route.startsWith('/admin/')) return this.#admin(route, request)
1198
+
1199
+ if (route === '/wake' && request.method === 'GET') return this.#wake(request)
1200
+ if (route === '/notify' && request.method === 'POST') {
1201
+ try {
1202
+ const applied = await this.#ingest(upstreamPath)
1203
+ return json({ ok: true, applied })
1204
+ } catch (error) {
1205
+ return json({ error: errorMessage(error) }, statusOf(error))
1206
+ }
1207
+ }
1208
+
1209
+ const claims = claimsFromRequest(request)
1210
+ if (!claims) return json({ error: 'missing normalized claims' }, 401)
1211
+ if (route === '/pull' && request.method === 'POST') {
1212
+ try {
1213
+ await this.#ingest(upstreamPath)
1214
+ } catch (error) {
1215
+ return json({ error: errorMessage(error) }, statusOf(error))
1216
+ }
1217
+ return this.#pull(request, claims, namespace)
1218
+ }
1219
+ if (route === '/push' && request.method === 'POST') {
1220
+ return this.#push(request, claims, namespace, upstreamPath)
1221
+ }
1222
+ return json({ error: 'not found' }, 404)
1223
+ }
1224
+
1225
+ async alarm(): Promise<void> {
1226
+ try {
1227
+ await this.#ingest()
1228
+ } catch (error) {
1229
+ console.error(
1230
+ JSON.stringify({
1231
+ event: 'sync_upstream_ingest_error',
1232
+ status: statusOf(error),
1233
+ error: errorMessage(error),
1234
+ })
1235
+ )
1236
+ } finally {
1237
+ await this.ctx.storage.setAlarm(Date.now() + upstreamIntervalMs)
1238
+ }
1239
+ }
1240
+
1241
+ webSocketMessage(socket: WebSocket, message: string | ArrayBuffer): void {
1242
+ if (message === 'ping') socket.send('pong')
1243
+ }
1244
+
1245
+ webSocketClose(
1246
+ socket: WebSocket,
1247
+ code: number,
1248
+ reason: string,
1249
+ _wasClean: boolean
1250
+ ): void {
1251
+ // The peer already closed, so echo the close to release the socket — but
1252
+ // WebSocket.close() rejects reserved/absent codes (1005 "no status", 1006
1253
+ // abnormal, 1015) with InvalidAccessError, and a real browser routinely
1254
+ // closes with 1001/1005. An uncaught throw here aborts the DO, so only
1255
+ // echo an application-permitted code and otherwise close cleanly.
1256
+ const echoable = code === 1000 || (code >= 3000 && code <= 4999)
1257
+ socketCloseQuietly(socket, echoable ? code : 1000, echoable ? reason : '')
1258
+ }
1259
+
1260
+ webSocketError(socket: WebSocket, _error: unknown): void {
1261
+ socketCloseQuietly(socket, 1011, 'wake socket error')
1262
+ }
1263
+ }
1264
+ }