orez-sync-cf-host 0.5.18 → 0.5.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +117 -7
  2. package/dist/bun-wasm-loader.d.ts +1 -0
  3. package/dist/bun-wasm-loader.js +20 -0
  4. package/dist/config.d.ts +3 -0
  5. package/dist/config.js +93 -0
  6. package/{src → dist}/generated/sync_wasm.d.ts +2 -2
  7. package/{src → dist}/generated/sync_wasm.js +3 -2
  8. package/dist/generated/sync_wasm_bg.wasm +0 -0
  9. package/dist/host.d.ts +12 -0
  10. package/dist/host.js +1512 -0
  11. package/dist/index.d.ts +8 -0
  12. package/dist/index.js +4 -0
  13. package/dist/mutation-error.d.ts +9 -0
  14. package/dist/mutation-error.js +23 -0
  15. package/dist/node-wasm-loader.d.ts +1 -0
  16. package/dist/node-wasm-loader.js +22 -0
  17. package/dist/post-commit.d.ts +13 -0
  18. package/dist/post-commit.js +31 -0
  19. package/dist/query-compiler.d.ts +4 -0
  20. package/dist/query-compiler.js +4 -0
  21. package/dist/sql-storage-adapter.d.ts +69 -0
  22. package/dist/sql-storage-adapter.js +192 -0
  23. package/dist/transaction-query.d.ts +72 -0
  24. package/dist/transaction-query.js +379 -0
  25. package/dist/types.d.ts +144 -0
  26. package/dist/types.js +5 -0
  27. package/dist/vite-wasm-loader.d.ts +3 -0
  28. package/dist/vite-wasm-loader.js +24 -0
  29. package/dist/wasm.d.ts +1 -0
  30. package/dist/wasm.js +7 -0
  31. package/dist/write-safeguards.d.ts +53 -0
  32. package/dist/write-safeguards.js +186 -0
  33. package/package.json +63 -12
  34. package/src/config.ts +0 -93
  35. package/src/generated/sync_wasm_bg.wasm +0 -0
  36. package/src/harness-config.ts +0 -487
  37. package/src/harness-worker.ts +0 -33
  38. package/src/host.ts +0 -1948
  39. package/src/index.ts +0 -22
  40. package/src/ingest-harness-worker.ts +0 -440
  41. package/src/platform-probe-worker.ts +0 -305
  42. package/src/sql-storage-adapter.ts +0 -185
  43. package/src/types.ts +0 -181
  44. package/src/write-safeguards.ts +0 -248
  45. /package/{src → dist}/generated/package.json +0 -0
  46. /package/{src → dist}/generated/sync_wasm_bg.wasm.d.ts +0 -0
package/src/host.ts DELETED
@@ -1,1948 +0,0 @@
1
- import { DurableObject } from 'cloudflare:workers'
2
-
3
- import { validatePullCaps, validateSyncHostConfig } from './config.js'
4
- import {
5
- engine_apply_snapshot_changes,
6
- engine_apply_snapshot_page,
7
- engine_apply_upstream,
8
- engine_assemble_push_response,
9
- engine_begin_snapshot_generation,
10
- engine_compile_query,
11
- engine_finalize,
12
- engine_finalize_snapshot_generation,
13
- engine_handle_pull,
14
- engine_handle_query_pull,
15
- engine_init_query_schema,
16
- engine_init_schema,
17
- engine_invalidate,
18
- engine_memory_bytes,
19
- engine_preflight,
20
- engine_prune,
21
- engine_push_validate,
22
- engine_read_snapshot_progress,
23
- engine_record_app_error,
24
- engine_state,
25
- engine_version,
26
- initSync,
27
- } from './generated/sync_wasm.js'
28
- import wasmModule from './generated/sync_wasm_bg.wasm'
29
- import {
30
- SqlStorageDirect,
31
- SqlStorageMutatorTransaction,
32
- SqlStorageSyncDb,
33
- } from './sql-storage-adapter.js'
34
- import { MutationApplicationError } from './types.js'
35
- import {
36
- IngestBreakerError,
37
- IngestCircuitBreaker,
38
- retryDelayMs,
39
- shouldRetryDelegatedPush,
40
- } from './write-safeguards.js'
41
-
42
- import type {
43
- DeferredEffect,
44
- JsonValue,
45
- NormalizedClaims,
46
- PullCaps,
47
- SyncHostConfig,
48
- SyncHostEnv,
49
- } from './types.js'
50
-
51
- initSync({ module: wasmModule })
52
-
53
- const CLAIMS_HEADER = 'x-orez-sync-claims'
54
- const NAMESPACE_HEADER = 'x-orez-sync-namespace'
55
- const UPSTREAM_PATH_HEADER = 'x-orez-sync-upstream-path'
56
- const DEFAULT_SNAPSHOT_PAGE_ROWS = 2_000
57
- const MIN_SNAPSHOT_PAGE_ROWS = 100
58
- const DEFAULT_CAPS: PullCaps = {
59
- maxChangeRows: 10_000,
60
- maxChangeBytes: 2_000_000,
61
- }
62
-
63
- type PushMutation = {
64
- id: string
65
- clientID: string
66
- name: string
67
- args: JsonValue[]
68
- }
69
-
70
- type PushPlan =
71
- | { kind: 'respond'; response: unknown }
72
- | { kind: 'process'; clientGroupID: string; mutations: PushMutation[] }
73
-
74
- type Preflight = { kind: 'applied' } | { kind: 'replay'; expected: string }
75
-
76
- type MutationResult = {
77
- clientID: string
78
- id: string
79
- result: Record<string, unknown>
80
- }
81
-
82
- type DelegatedMutationResult = { id?: { clientID?: unknown; id?: unknown } }
83
- type DelegatedPushBody = {
84
- mutations?: DelegatedMutationResult[]
85
- pushResponse?: unknown
86
- [key: string]: unknown
87
- }
88
-
89
- type EngineState = { watermark: string; floor: string; upstreamWatermark: string }
90
- type UpstreamBatch = {
91
- watermark: number
92
- changes: Array<{
93
- watermark: number
94
- tableName: string
95
- op: string
96
- rowData: Record<string, unknown> | null
97
- oldData: Record<string, unknown> | null
98
- }>
99
- }
100
- type ApplyUpstreamResult = {
101
- watermark: number | string
102
- applied: number
103
- caughtUp: boolean
104
- }
105
- type SnapshotProgress = {
106
- generation: string
107
- startWatermark: string
108
- table: string | null
109
- cursor: string | null
110
- state: 'paging' | 'catching_up'
111
- catchupWatermark: string
112
- }
113
- type SnapshotPage = {
114
- watermark: number
115
- rows: Record<string, unknown>[]
116
- nextCursor: string | null
117
- }
118
- type SocketAttachment = { clientID: string }
119
- type FaultPoint =
120
- | 'push_before_mutation'
121
- | 'push_after_write_before_commit'
122
- | 'push_after_commit_before_response'
123
- | 'pull_during_tx'
124
- | 'pull_after_commit'
125
- type FaultKind = 'error' | 'quota'
126
-
127
- type Counters = {
128
- pulls: number
129
- pushes: number
130
- resets: number
131
- applicationErrors: number
132
- invariantFailures: number
133
- retentionRuns: number
134
- queryRecompilations: number
135
- wasmBoundaryCalls: number
136
- wakeFrames: number
137
- wakeBatches: number
138
- externalEffectFailures: number
139
- }
140
-
141
- function freshCounters(): Counters {
142
- return {
143
- pulls: 0,
144
- pushes: 0,
145
- resets: 0,
146
- applicationErrors: 0,
147
- invariantFailures: 0,
148
- retentionRuns: 0,
149
- queryRecompilations: 0,
150
- wasmBoundaryCalls: 0,
151
- wakeFrames: 0,
152
- wakeBatches: 0,
153
- externalEffectFailures: 0,
154
- }
155
- }
156
-
157
- function json(value: unknown, status = 200): Response {
158
- return Response.json(value, {
159
- status,
160
- headers: { 'cache-control': 'no-store' },
161
- })
162
- }
163
-
164
- function isStructuredPushFailed(value: unknown): value is Record<string, unknown> {
165
- if (typeof value !== 'object' || value === null) return false
166
- const body = value as Record<string, unknown>
167
- return (
168
- body.kind === 'PushFailed' &&
169
- typeof body.origin === 'string' &&
170
- typeof body.reason === 'string' &&
171
- typeof body.message === 'string' &&
172
- Array.isArray(body.mutationIDs)
173
- )
174
- }
175
-
176
- function statusOf(error: unknown): number {
177
- if (typeof error === 'object' && error !== null && 'status' in error) {
178
- const status = Number(error.status)
179
- if (Number.isInteger(status) && status >= 400 && status <= 599) return status
180
- }
181
- return 500
182
- }
183
-
184
- function errorMessage(error: unknown): string {
185
- return error instanceof Error ? error.message : String(error)
186
- }
187
-
188
- function errorBody(error: unknown): Record<string, unknown> {
189
- if (error instanceof IngestBreakerError) {
190
- return {
191
- error: error.error,
192
- windowRows: error.windowRows,
193
- budget: error.budget,
194
- retryAfterMs: error.retryAfterMs,
195
- }
196
- }
197
- return { error: errorMessage(error) }
198
- }
199
-
200
- function requestError(message: string, status = 400): Error & { status: number } {
201
- return Object.assign(new Error(message), { status })
202
- }
203
-
204
- async function requestJson(request: Request): Promise<unknown> {
205
- try {
206
- return await request.json()
207
- } catch {
208
- throw requestError('invalid JSON request body')
209
- }
210
- }
211
-
212
- async function requestObject(request: Request): Promise<Record<string, unknown>> {
213
- const value = await requestJson(request)
214
- if (!value || typeof value !== 'object' || Array.isArray(value))
215
- throw requestError('request body must be a JSON object')
216
- return value as Record<string, unknown>
217
- }
218
-
219
- function routeAfterNamespace(pathname: string): string {
220
- const [, , ...parts] = pathname.split('/')
221
- return `/${parts.join('/')}`
222
- }
223
-
224
- function claimsFromRequest(request: Request): NormalizedClaims | null {
225
- const encoded = request.headers.get(CLAIMS_HEADER)
226
- if (!encoded) return null
227
- try {
228
- const value = JSON.parse(decodeURIComponent(encoded)) as NormalizedClaims
229
- return value && typeof value.userID === 'string' && value.userID.length > 0
230
- ? value
231
- : null
232
- } catch {
233
- return null
234
- }
235
- }
236
-
237
- async function namespaceHash(namespace: string): Promise<string> {
238
- const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(namespace))
239
- return Array.from(new Uint8Array(bytes).slice(0, 8), (byte) =>
240
- byte.toString(16).padStart(2, '0')
241
- ).join('')
242
- }
243
-
244
- function socketAttachment(socket: WebSocket): SocketAttachment | null {
245
- const value = socket.deserializeAttachment() as SocketAttachment | null
246
- return value && typeof value.clientID === 'string' ? value : null
247
- }
248
-
249
- // Closing a socket that is already closed/closing throws, and a throw inside a
250
- // hibernatable WebSocket handler aborts the DO. Swallow it: the socket is going
251
- // away regardless.
252
- function socketCloseQuietly(socket: WebSocket, code: number, reason: string): void {
253
- try {
254
- socket.close(code, reason)
255
- } catch {
256
- // already closing/closed, or workerd rejected the code — nothing to do
257
- }
258
- }
259
-
260
- /**
261
- * Create the consumer-facing Worker router. Authentication happens here; the
262
- * Durable Object receives only normalized claims over a binding-private header.
263
- */
264
- export function createSyncWorker<Env extends SyncHostEnv>(
265
- config: SyncHostConfig<Env>
266
- ): ExportedHandler<Env> {
267
- validateSyncHostConfig(config)
268
- return {
269
- async fetch(request, env): Promise<Response> {
270
- const namespace = config.namespace(request)
271
- if (!namespace) return new Response('orez sync-cf-host', { status: 200 })
272
-
273
- const route = routeAfterNamespace(new URL(request.url).pathname)
274
- const isAdmin = route.startsWith('/admin/')
275
- if (route === '/wake') {
276
- if (!(await config.authorizeWake(request, env))) {
277
- return json({ error: 'missing wake capability' }, 401)
278
- }
279
- } else if (route === '/notify') {
280
- if (!(await config.authorizeNotify(request, env))) {
281
- return json({ error: 'forbidden' }, 403)
282
- }
283
- } else if (isAdmin) {
284
- const authorized = config.authorizeAdmin
285
- ? await config.authorizeAdmin(request, env)
286
- : Boolean(env.ADMIN_KEY) && request.headers.get('x-admin-key') === env.ADMIN_KEY
287
- if (!authorized) return json({ error: 'forbidden' }, 403)
288
- }
289
-
290
- const headers = new Headers(request.headers)
291
- headers.delete(CLAIMS_HEADER)
292
- headers.delete(NAMESPACE_HEADER)
293
- headers.delete(UPSTREAM_PATH_HEADER)
294
- if (!isAdmin && route !== '/wake' && route !== '/notify') {
295
- const claims = await config.authenticate(request, env)
296
- if (!claims) return json({ error: 'missing authentication' }, 401)
297
- headers.set(CLAIMS_HEADER, encodeURIComponent(JSON.stringify(claims)))
298
- }
299
- headers.set(NAMESPACE_HEADER, await namespaceHash(namespace))
300
- if (config.upstream) {
301
- const namespacePath =
302
- typeof config.upstream.namespacePath === 'function'
303
- ? config.upstream.namespacePath(namespace)
304
- : config.upstream.namespacePath
305
- if (!namespacePath.startsWith('/')) {
306
- return json({ error: 'upstream namespacePath must be an absolute path' }, 500)
307
- }
308
- headers.set(UPSTREAM_PATH_HEADER, namespacePath.replace(/\/$/, ''))
309
- }
310
-
311
- const forwarded = new Request(request, { headers })
312
- const id = env.SYNC_DO.idFromName(namespace)
313
- return env.SYNC_DO.get(id).fetch(forwarded)
314
- },
315
- }
316
- }
317
-
318
- /** Create the namespace Durable Object class for one bundled consumer config. */
319
- export function createSyncDurableObject<Env extends SyncHostEnv>(
320
- config: SyncHostConfig<Env>
321
- ) {
322
- validateSyncHostConfig(config)
323
- const defaultRetainChanges = String(config.retainChanges ?? 4_096)
324
- const caps: PullCaps = validatePullCaps({ ...DEFAULT_CAPS, ...config.caps })
325
- const idleTeardownMs = config.idleTeardownMs ?? 5_000
326
- // A CF fan-out wakes every client into an HTTP pull. Give concurrent writer
327
- // requests a real batching window so a storm burst creates one pull wave.
328
- const wakeCoalesceMs = config.wakeCoalesceMs ?? 25
329
- const upstreamIntervalMs = config.upstream?.intervalMs ?? 15_000
330
- const upstreamLimit = config.upstream?.changeLimit ?? 1_000
331
- const ingestBudgetRows = config.upstream?.ingestBudgetRows ?? 150_000
332
- const ingestBudgetWindowMs = config.upstream?.ingestBudgetWindowMs ?? 5 * 60_000
333
- const ingestBackoffMs = config.upstream?.ingestBackoffMs ?? 1_000
334
- const ingestMaxBackoffMs = config.upstream?.ingestMaxBackoffMs ?? 60_000
335
- const delegateMaxAttempts = config.delegatedPushRetry?.maxAttempts ?? 3
336
- const delegateInitialBackoffMs = config.delegatedPushRetry?.initialBackoffMs ?? 100
337
- const delegateMaxBackoffMs = config.delegatedPushRetry?.maxBackoffMs ?? 1_000
338
- const delegateTimeoutMs = config.delegatedPushRetry?.timeoutMs ?? 5_000
339
-
340
- return class SyncDurableObject extends DurableObject<Env> {
341
- readonly #engineDb: SqlStorageSyncDb
342
- readonly #directSql: SqlStorageDirect
343
- readonly #mutatorSql: SqlStorageMutatorTransaction
344
- #bootID = crypto.randomUUID()
345
- #lastRequestAt = 0
346
- #hibernations = 0
347
- #dropNextPushResponse = false
348
- #counters = freshCounters()
349
- #pulling = new Set<string>()
350
- #wakeOrigins = new Set<string>()
351
- #wakeRecipients = new Set<WebSocket>()
352
- #wakePromise: Promise<void> | null = null
353
- #ingestPromise: Promise<number> | null = null
354
- #queryPullLocks = new Map<string, Promise<void>>()
355
- #recordingIngestBillable = false
356
- #ingestBreaker = new IngestCircuitBreaker({
357
- budgetRows: ingestBudgetRows,
358
- windowMs: ingestBudgetWindowMs,
359
- initialBackoffMs: ingestBackoffMs,
360
- maxBackoffMs: ingestMaxBackoffMs,
361
- now: () => Date.now(),
362
- })
363
-
364
- constructor(ctx: DurableObjectState, env: Env) {
365
- super(ctx, env)
366
- const recordRowsWritten = (rows: number) => {
367
- if (this.#recordingIngestBillable) this.#ingestBreaker.recordBillable(rows)
368
- }
369
- this.#engineDb = new SqlStorageSyncDb(ctx.storage.sql, recordRowsWritten)
370
- this.#directSql = new SqlStorageDirect(ctx.storage.sql, recordRowsWritten)
371
- this.#mutatorSql = new SqlStorageMutatorTransaction(this.#directSql, (ast) =>
372
- this.#wasm(() => engine_compile_query(config.schema, ast))
373
- )
374
- ctx.blockConcurrencyWhile(async () => {
375
- ctx.storage.transactionSync(() => {
376
- config.initialize(this.#directSql)
377
- this.#directSql.exec(`CREATE TABLE IF NOT EXISTS _zsync_host_control (
378
- key TEXT PRIMARY KEY,
379
- value TEXT NOT NULL
380
- )`)
381
- this.#directSql.exec(
382
- "INSERT OR IGNORE INTO _zsync_host_control (key, value) VALUES ('writerEnabled', '1')"
383
- )
384
- const ingestBreakerReason = this.#controlGet('ingestBreakerReason')
385
- if (
386
- ingestBreakerReason === 'ingestBudgetExceeded' ||
387
- ingestBreakerReason === 'ingestCursorStalled'
388
- ) {
389
- this.#ingestBreaker.restore(
390
- ingestBreakerReason,
391
- Number(this.#controlGet('ingestBreakerRetryAt')),
392
- Number(this.#controlGet('ingestBreakerTrips'))
393
- )
394
- }
395
- this.#wasm(() => engine_init_schema(this.#engineDb, config.schema))
396
- if (config.queryAware || config.resolveQuery)
397
- this.#wasm(() => engine_init_query_schema(this.#engineDb))
398
- })
399
- })
400
- }
401
-
402
- #armUpstreamAlarm(): void {
403
- if (!config.upstream) return
404
- this.ctx.waitUntil(
405
- (async () => {
406
- if ((await this.ctx.storage.getAlarm()) === null) {
407
- await this.ctx.storage.setAlarm(Date.now() + upstreamIntervalMs)
408
- }
409
- })()
410
- )
411
- }
412
-
413
- #wasm<T>(call: () => T): T {
414
- this.#counters.wasmBoundaryCalls++
415
- return call()
416
- }
417
-
418
- async #acquireQueryPullLock(clientGroupID: string): Promise<() => void> {
419
- const previous = this.#queryPullLocks.get(clientGroupID)
420
- let release!: () => void
421
- const current = new Promise<void>((resolve) => {
422
- release = resolve
423
- })
424
- this.#queryPullLocks.set(clientGroupID, current)
425
- if (previous) await previous
426
- return () => {
427
- release()
428
- if (this.#queryPullLocks.get(clientGroupID) === current) {
429
- this.#queryPullLocks.delete(clientGroupID)
430
- }
431
- }
432
- }
433
-
434
- #simulateIdleTeardown(now: number): void {
435
- if (this.#lastRequestAt > 0 && now - this.#lastRequestAt >= idleTeardownMs) {
436
- this.#bootID = crypto.randomUUID()
437
- this.#hibernations++
438
- this.#counters = freshCounters()
439
- this.#pulling.clear()
440
- this.#wakeOrigins.clear()
441
- this.#wakeRecipients.clear()
442
- this.#wakePromise = null
443
- }
444
- this.#lastRequestAt = now
445
- }
446
-
447
- #visibility(claims: NormalizedClaims): unknown {
448
- if (!config.visibility || !this.#visibilityEnabled()) return null
449
- return {
450
- rowLocal:
451
- typeof config.visibility.rowLocal === 'function'
452
- ? config.visibility.rowLocal(claims)
453
- : config.visibility.rowLocal,
454
- filters: Object.keys(config.schema.tables).flatMap((table) => {
455
- const filter = config.visibility?.filter(table, claims)
456
- return filter
457
- ? [{ table, sql: filter.sql, params: [...(filter.params ?? [])] }]
458
- : []
459
- }),
460
- }
461
- }
462
-
463
- // the admin-set namespace knobs (writer, visibility, query-aware,
464
- // retention) live in _zsync_host_control, NOT in instance fields: a real
465
- // eviction recreates this instance, and an in-memory override silently
466
- // reverting to the config default mid-run turns a query-aware namespace
467
- // back into a baseline one — the client keeps re-sending its desired-query
468
- // patch, the host ignores it, and every pull answers {unchanged:true}
469
- // forever.
470
- #controlGet(key: string): string | null {
471
- const row = this.#directSql.query<{ value: string }>(
472
- 'SELECT value FROM _zsync_host_control WHERE key = ?',
473
- [key]
474
- )[0]
475
- return row?.value ?? null
476
- }
477
-
478
- #controlSet(key: string, value: string): void {
479
- this.#directSql.exec(
480
- 'INSERT INTO _zsync_host_control (key, value) VALUES (?, ?) ' +
481
- 'ON CONFLICT(key) DO UPDATE SET value = excluded.value',
482
- [key, value]
483
- )
484
- }
485
-
486
- #controlDelete(...keys: string[]): void {
487
- if (keys.length === 0) return
488
- this.#directSql.exec(
489
- `DELETE FROM _zsync_host_control WHERE key IN (${keys.map(() => '?').join(', ')})`,
490
- keys
491
- )
492
- }
493
-
494
- #persistIngestBreaker(): void {
495
- const status = this.#ingestBreaker.status()
496
- if (!status.reason || status.retryAt === null) return
497
- this.#controlSet('ingestBreakerReason', status.reason)
498
- this.#controlSet('ingestBreakerRetryAt', String(status.retryAt))
499
- this.#controlSet('ingestBreakerTrips', String(status.consecutiveTrips))
500
- }
501
-
502
- #recoverIngestBreaker(): void {
503
- const wasTripped = this.#ingestBreaker.status().reason !== null
504
- this.#ingestBreaker.recovered()
505
- if (wasTripped) {
506
- this.#controlDelete(
507
- 'ingestBreakerReason',
508
- 'ingestBreakerRetryAt',
509
- 'ingestBreakerTrips'
510
- )
511
- }
512
- }
513
-
514
- #writerEnabled(): boolean {
515
- return this.#controlGet('writerEnabled') === '1'
516
- }
517
-
518
- #visibilityEnabled(): boolean {
519
- const value = this.#controlGet('visibilityEnabled')
520
- return value === null ? (config.visibilityEnabled ?? false) : value === '1'
521
- }
522
-
523
- #queryAwareOverride(): boolean | null {
524
- const value = this.#controlGet('queryAwareOverride')
525
- return value === null ? null : value === '1'
526
- }
527
-
528
- #retainChanges(): string {
529
- return this.#controlGet('retainChanges') ?? defaultRetainChanges
530
- }
531
-
532
- #takeFault(point: FaultPoint): FaultKind | null {
533
- if (this.#controlGet('faultPoint') !== point) return null
534
- const kind = this.#controlGet('faultKind')
535
- this.#directSql.exec(
536
- "DELETE FROM _zsync_host_control WHERE key IN ('faultPoint', 'faultKind')"
537
- )
538
- return kind === 'quota' ? 'quota' : 'error'
539
- }
540
-
541
- #faultError(kind: FaultKind, point: FaultPoint): Error & { status: number } {
542
- return requestError(
543
- `injected ${kind} fault at ${point}`,
544
- kind === 'quota' ? 507 : 500
545
- )
546
- }
547
-
548
- #engineState(): EngineState {
549
- return this.#wasm(() => engine_state(this.#engineDb)) as EngineState
550
- }
551
-
552
- #engineStateBestEffort(): EngineState | null {
553
- try {
554
- return this.#engineState()
555
- } catch {
556
- return null
557
- }
558
- }
559
-
560
- #serviceBinding(name = config.upstream?.binding): {
561
- fetch(input: string | Request, init?: RequestInit): Promise<Response>
562
- } {
563
- const value = name
564
- ? (this.env as unknown as Record<string, unknown>)[name]
565
- : undefined
566
- if (!value || typeof (value as { fetch?: unknown }).fetch !== 'function') {
567
- throw requestError(
568
- `missing upstream service binding: ${name ?? '(not configured)'}`,
569
- 500
570
- )
571
- }
572
- return value as {
573
- fetch(input: string | Request, init?: RequestInit): Promise<Response>
574
- }
575
- }
576
-
577
- async #upstreamWriteBudgetStatus(): Promise<Response> {
578
- if (!config.upstream) return json({ error: 'upstream is not configured' }, 404)
579
- const path = this.#controlGet('upstreamPath')
580
- if (path === null) return json({ error: 'upstream path is not known yet' }, 409)
581
- const endpoint = new URL(`${path}/_orez/write-budget`, 'https://upstream.invalid')
582
- const response = await this.#serviceBinding().fetch(endpoint.toString(), {
583
- headers: { host: endpoint.host },
584
- })
585
- if (!response.ok) {
586
- return json(
587
- {
588
- error: 'upstream write-budget status unavailable',
589
- upstreamStatus: response.status,
590
- },
591
- 502
592
- )
593
- }
594
- return json(await response.json())
595
- }
596
-
597
- #rememberUpstreamPath(request: Request): string | null {
598
- if (!config.upstream) return null
599
- const path = request.headers.get(UPSTREAM_PATH_HEADER)
600
- if (path !== null) {
601
- this.#controlSet('upstreamPath', path)
602
- return path
603
- }
604
- return this.#controlGet('upstreamPath')
605
- }
606
-
607
- #tripIngest(
608
- reason: 'ingestBudgetExceeded' | 'ingestCursorStalled',
609
- fields: Record<string, unknown>
610
- ): never {
611
- try {
612
- return this.#ingestBreaker.trip(reason)
613
- } catch (error) {
614
- this.#persistIngestBreaker()
615
- const status = this.#ingestBreaker.status()
616
- console.error(
617
- JSON.stringify({
618
- event: 'sync_upstream_ingest_breaker_tripped',
619
- ...status,
620
- reason,
621
- ...fields,
622
- })
623
- )
624
- throw error
625
- }
626
- }
627
-
628
- #recordIngestLogicalRows(rows: number): void {
629
- this.#ingestBreaker.recordLogical(rows)
630
- }
631
-
632
- #withIngestBilling<T>(fields: Record<string, unknown>, apply: () => T): T {
633
- this.#recordingIngestBillable = true
634
- try {
635
- return apply()
636
- } catch (error) {
637
- this.#recordingIngestBillable = false
638
- const status = this.#ingestBreaker.status()
639
- // a breaker thrown by the sql adapter crosses rust as an engine error,
640
- // so the durable breaker state is the authoritative classification.
641
- if (
642
- error instanceof IngestBreakerError ||
643
- (status.tripped && status.reason === 'ingestBudgetExceeded')
644
- ) {
645
- this.#persistIngestBreaker()
646
- console.error(
647
- JSON.stringify({
648
- event: 'sync_upstream_ingest_breaker_tripped',
649
- ...status,
650
- reason: 'ingestBudgetExceeded',
651
- ...fields,
652
- })
653
- )
654
- }
655
- throw error
656
- } finally {
657
- this.#recordingIngestBillable = false
658
- }
659
- }
660
-
661
- #snapshotProgress(): SnapshotProgress | null {
662
- return this.#wasm(() =>
663
- engine_read_snapshot_progress(this.#engineDb)
664
- ) as SnapshotProgress | null
665
- }
666
-
667
- #resetSnapshotBillingWindow(): void {
668
- // every page is an independently committed write unit. metering it in a
669
- // fresh window keeps a rebuild larger than the breaker ceiling resumable
670
- // while preserving the ceiling for each transaction.
671
- this.#ingestBreaker.reopen()
672
- this.#controlDelete(
673
- 'ingestBreakerReason',
674
- 'ingestBreakerRetryAt',
675
- 'ingestBreakerTrips'
676
- )
677
- }
678
-
679
- #snapshotRetryLimit(
680
- error: unknown,
681
- limit: number,
682
- fields: Record<string, unknown>
683
- ): number {
684
- const status = statusOf(error)
685
- if (!(error instanceof IngestBreakerError) && status < 500) throw error
686
- if (limit <= MIN_SNAPSHOT_PAGE_ROWS) {
687
- throw Object.assign(
688
- new Error(
689
- `snapshot page failed at minimum limit ${MIN_SNAPSHOT_PAGE_ROWS}: ${errorMessage(error)}`
690
- ),
691
- { status, cause: error }
692
- )
693
- }
694
- const nextLimit = Math.max(MIN_SNAPSHOT_PAGE_ROWS, Math.floor(limit / 2))
695
- console.warn(
696
- JSON.stringify({
697
- event: 'sync_upstream_snapshot_page_retry',
698
- ...fields,
699
- limit,
700
- nextLimit,
701
- status,
702
- error: errorMessage(error),
703
- })
704
- )
705
- return nextLimit
706
- }
707
-
708
- async #fetchSnapshotPage(
709
- path: string,
710
- table: string,
711
- cursor: string | null,
712
- limit: number
713
- ): Promise<SnapshotPage> {
714
- const endpoint = new URL(`${path}/snapshot`, 'https://upstream.invalid')
715
- endpoint.searchParams.set('table', table)
716
- endpoint.searchParams.set('limit', String(limit))
717
- if (cursor !== null) endpoint.searchParams.set('cursor', cursor)
718
- const response = await this.#serviceBinding().fetch(endpoint.toString(), {
719
- headers: { host: endpoint.host },
720
- })
721
- if (!response.ok) {
722
- throw requestError(
723
- `upstream snapshot page returned ${response.status}`,
724
- response.status >= 500 ? 502 : response.status
725
- )
726
- }
727
- const page = (await response.json()) as Partial<SnapshotPage>
728
- if (
729
- !Number.isSafeInteger(page.watermark) ||
730
- Number(page.watermark) < 0 ||
731
- !Array.isArray(page.rows) ||
732
- (page.nextCursor !== null && typeof page.nextCursor !== 'string')
733
- ) {
734
- throw new Error('invalid upstream snapshot page response')
735
- }
736
- return page as SnapshotPage
737
- }
738
-
739
- async #beginSnapshotGeneration(path: string): Promise<{
740
- progress: SnapshotProgress
741
- page: SnapshotPage
742
- pageLimit: number
743
- }> {
744
- const table = Object.keys(config.schema.tables).sort()[0]
745
- if (!table) throw requestError('paged snapshots require a modeled table', 500)
746
- let pageLimit = DEFAULT_SNAPSHOT_PAGE_ROWS
747
- let page: SnapshotPage
748
- for (;;) {
749
- try {
750
- page = await this.#fetchSnapshotPage(path, table, null, pageLimit)
751
- break
752
- } catch (error) {
753
- pageLimit = this.#snapshotRetryLimit(error, pageLimit, {
754
- phase: 'snapshot_page_fetch',
755
- table,
756
- cursor: null,
757
- })
758
- }
759
- }
760
- this.#resetSnapshotBillingWindow()
761
- const progress = this.#withIngestBilling(
762
- {
763
- phase: 'snapshot_begin',
764
- table,
765
- startWatermark: page.watermark,
766
- },
767
- () =>
768
- this.ctx.storage.transactionSync(() =>
769
- this.#wasm(() =>
770
- engine_begin_snapshot_generation(
771
- this.#engineDb,
772
- config.schema,
773
- String(page.watermark)
774
- )
775
- )
776
- )
777
- ) as SnapshotProgress
778
- return { progress, page, pageLimit }
779
- }
780
-
781
- #ingest(upstreamPath?: string | null, forceSnapshot = false): Promise<number> {
782
- if (!config.upstream) {
783
- return forceSnapshot
784
- ? Promise.reject(requestError('upstream is not configured'))
785
- : Promise.resolve(0)
786
- }
787
- if (this.#ingestPromise) {
788
- return forceSnapshot
789
- ? this.#ingestPromise.then(() => this.#ingest(upstreamPath, true))
790
- : this.#ingestPromise
791
- }
792
- const path = upstreamPath ?? this.#controlGet('upstreamPath')
793
- if (path === null) {
794
- return forceSnapshot
795
- ? Promise.reject(requestError('upstream path is not available'))
796
- : Promise.resolve(0)
797
- }
798
- this.#ingestPromise = (async () => {
799
- let progress = this.#snapshotProgress()
800
- this.#ingestBreaker.assertReady()
801
- const startingWatermark = this.#engineState().watermark
802
- let total = 0
803
- let pendingPage: SnapshotPage | null = null
804
- let snapshotPageLimit = DEFAULT_SNAPSHOT_PAGE_ROWS
805
- let snapshotCompleted = false
806
- for (;;) {
807
- if (progress?.state === 'paging') {
808
- const activeProgress = progress
809
- const table = activeProgress.table
810
- if (table === null) {
811
- throw new Error(
812
- `snapshot generation ${activeProgress.generation} is paging without a table`
813
- )
814
- }
815
- let page: SnapshotPage | null = pendingPage
816
- try {
817
- page ??= await this.#fetchSnapshotPage(
818
- path,
819
- table,
820
- activeProgress.cursor,
821
- snapshotPageLimit
822
- )
823
- const pageToApply = page
824
- this.#resetSnapshotBillingWindow()
825
- const nextProgress = this.#withIngestBilling(
826
- {
827
- phase: 'snapshot_page_apply',
828
- generation: activeProgress.generation,
829
- table,
830
- cursor: activeProgress.cursor,
831
- pageRows: pageToApply.rows.length,
832
- pageLimit: snapshotPageLimit,
833
- },
834
- () =>
835
- this.ctx.storage.transactionSync(() =>
836
- this.#wasm(() =>
837
- engine_apply_snapshot_page(
838
- this.#engineDb,
839
- config.schema,
840
- activeProgress.generation,
841
- table,
842
- pageToApply.rows,
843
- pageToApply.nextCursor
844
- )
845
- )
846
- )
847
- ) as SnapshotProgress
848
- total += pageToApply.rows.length
849
- this.#recordIngestLogicalRows(pageToApply.rows.length)
850
- progress = nextProgress
851
- pendingPage = null
852
- } catch (error) {
853
- snapshotPageLimit = this.#snapshotRetryLimit(error, snapshotPageLimit, {
854
- phase: page === null ? 'snapshot_page_fetch' : 'snapshot_page_apply',
855
- generation: activeProgress.generation,
856
- table,
857
- cursor: activeProgress.cursor,
858
- })
859
- pendingPage = null
860
- }
861
- continue
862
- }
863
-
864
- if (progress?.state === 'catching_up') {
865
- const activeProgress = progress
866
- const cursor = activeProgress.catchupWatermark
867
- const endpoint = new URL(`${path}/changes`, 'https://upstream.invalid')
868
- endpoint.searchParams.set('since', cursor)
869
- endpoint.searchParams.set('limit', String(upstreamLimit))
870
- const response = await this.#serviceBinding().fetch(endpoint.toString(), {
871
- headers: { host: endpoint.host },
872
- })
873
- if (response.status === 410) {
874
- const begun = await this.#beginSnapshotGeneration(path)
875
- progress = begun.progress
876
- pendingPage = begun.page
877
- snapshotPageLimit = begun.pageLimit
878
- continue
879
- }
880
- if (!response.ok) {
881
- throw new Error(`upstream snapshot catch-up returned ${response.status}`)
882
- }
883
- const batch = (await response.json()) as UpstreamBatch
884
- if (!Number.isSafeInteger(batch.watermark) || !Array.isArray(batch.changes)) {
885
- throw new Error('invalid upstream changes response')
886
- }
887
- this.#resetSnapshotBillingWindow()
888
- const result = this.#withIngestBilling(
889
- {
890
- phase: 'snapshot_catchup',
891
- generation: activeProgress.generation,
892
- cursor,
893
- batchWatermark: batch.watermark,
894
- changeRows: batch.changes.length,
895
- },
896
- () =>
897
- this.ctx.storage.transactionSync(() =>
898
- this.#wasm(() =>
899
- engine_apply_snapshot_changes(
900
- this.#engineDb,
901
- config.schema,
902
- activeProgress.generation,
903
- batch
904
- )
905
- )
906
- )
907
- ) as ApplyUpstreamResult
908
- total += result.applied
909
- this.#recordIngestLogicalRows(result.applied)
910
- if (result.caughtUp) {
911
- this.#resetSnapshotBillingWindow()
912
- this.#withIngestBilling(
913
- {
914
- phase: 'snapshot_finalize',
915
- generation: activeProgress.generation,
916
- watermark: result.watermark,
917
- },
918
- () =>
919
- this.ctx.storage.transactionSync(() =>
920
- this.#wasm(() =>
921
- engine_finalize_snapshot_generation(
922
- this.#engineDb,
923
- config.schema,
924
- activeProgress.generation,
925
- String(result.watermark)
926
- )
927
- )
928
- )
929
- )
930
- progress = null
931
- snapshotCompleted = true
932
- break
933
- }
934
- if (String(result.watermark) === cursor) {
935
- this.#tripIngest('ingestCursorStalled', {
936
- phase: 'snapshot_catchup',
937
- generation: activeProgress.generation,
938
- cursor,
939
- batchWatermark: batch.watermark,
940
- changeRows: batch.changes.length,
941
- applied: result.applied,
942
- })
943
- }
944
- progress = {
945
- ...progress,
946
- catchupWatermark: String(result.watermark),
947
- }
948
- continue
949
- }
950
-
951
- const cursor = this.#engineState().upstreamWatermark
952
- if (forceSnapshot) {
953
- forceSnapshot = false
954
- const begun = await this.#beginSnapshotGeneration(path)
955
- progress = begun.progress
956
- pendingPage = begun.page
957
- snapshotPageLimit = begun.pageLimit
958
- continue
959
- }
960
- const endpoint = new URL(`${path}/changes`, 'https://upstream.invalid')
961
- endpoint.searchParams.set('watermark', cursor)
962
- endpoint.searchParams.set('limit', String(upstreamLimit))
963
- const response = await this.#serviceBinding().fetch(endpoint.toString(), {
964
- headers: { host: endpoint.host },
965
- })
966
- if (response.status === 410) {
967
- const begun = await this.#beginSnapshotGeneration(path)
968
- progress = begun.progress
969
- pendingPage = begun.page
970
- snapshotPageLimit = begun.pageLimit
971
- continue
972
- }
973
- if (!response.ok) {
974
- throw new Error(`upstream changes returned ${response.status}`)
975
- }
976
- const batch = (await response.json()) as UpstreamBatch
977
- if (!Number.isSafeInteger(batch.watermark) || !Array.isArray(batch.changes)) {
978
- throw new Error('invalid upstream changes response')
979
- }
980
- const result = this.#withIngestBilling(
981
- {
982
- phase: 'changes',
983
- cursor,
984
- batchWatermark: batch.watermark,
985
- changeRows: batch.changes.length,
986
- },
987
- () =>
988
- this.ctx.storage.transactionSync(() =>
989
- this.#wasm(() =>
990
- engine_apply_upstream(this.#engineDb, config.schema, batch)
991
- )
992
- )
993
- ) as ApplyUpstreamResult
994
- total += result.applied
995
- this.#recordIngestLogicalRows(result.applied)
996
- const nextCursor = this.#engineState().upstreamWatermark
997
- if (batch.changes.length > 0 && String(nextCursor) === String(cursor)) {
998
- this.#tripIngest('ingestCursorStalled', {
999
- phase: 'changes',
1000
- cursor,
1001
- batchWatermark: batch.watermark,
1002
- changeRows: batch.changes.length,
1003
- applied: result.applied,
1004
- })
1005
- }
1006
- if (result.caughtUp) break
1007
- // a page can legitimately apply zero rows while still advancing the
1008
- // watermark: the engine consumes changes for tables this host does not
1009
- // model (subset replica) without materializing them. only a page that
1010
- // neither applied nor advanced is genuinely stalled.
1011
- if (result.applied === 0 && String(nextCursor) === String(cursor)) {
1012
- this.#tripIngest('ingestCursorStalled', {
1013
- phase: 'changes',
1014
- cursor,
1015
- batchWatermark: batch.watermark,
1016
- changeRows: batch.changes.length,
1017
- applied: result.applied,
1018
- })
1019
- }
1020
- }
1021
- this.#recoverIngestBreaker()
1022
- const endingWatermark = this.#engineState().watermark
1023
- if (snapshotCompleted || total > 0 || endingWatermark !== startingWatermark) {
1024
- await this.#enqueueWake('__upstream__')
1025
- }
1026
- return total
1027
- })().finally(() => {
1028
- this.#ingestPromise = null
1029
- })
1030
- return this.#ingestPromise
1031
- }
1032
-
1033
- async #fetchDelegatedPush(
1034
- endpoint: URL,
1035
- headers: Headers,
1036
- body: ArrayBuffer,
1037
- provisioning = false
1038
- ): Promise<Response> {
1039
- const binding = this.#serviceBinding(
1040
- config.mutateBinding ?? config.upstream?.binding
1041
- )
1042
- let lastError: unknown = null
1043
- for (let attempt = 1; attempt <= delegateMaxAttempts; attempt++) {
1044
- let response: Response | null = null
1045
- try {
1046
- response = await binding.fetch(endpoint.toString(), {
1047
- method: 'POST',
1048
- headers,
1049
- body,
1050
- signal: AbortSignal.timeout(
1051
- provisioning ? Math.max(delegateTimeoutMs, 25_000) : delegateTimeoutMs
1052
- ),
1053
- })
1054
- } catch (error) {
1055
- lastError = error
1056
- }
1057
- if (
1058
- !shouldRetryDelegatedPush(
1059
- response?.status ?? null,
1060
- attempt,
1061
- delegateMaxAttempts
1062
- )
1063
- ) {
1064
- if (response) return response
1065
- throw lastError
1066
- }
1067
- await response?.body?.cancel()
1068
- const delayMs = retryDelayMs(
1069
- attempt,
1070
- delegateInitialBackoffMs,
1071
- delegateMaxBackoffMs
1072
- )
1073
- console.warn(
1074
- JSON.stringify({
1075
- event: 'sync_delegated_push_retry',
1076
- attempt,
1077
- maxAttempts: delegateMaxAttempts,
1078
- status: response?.status ?? null,
1079
- delayMs,
1080
- error: response ? null : errorMessage(lastError),
1081
- })
1082
- )
1083
- await scheduler.wait(delayMs)
1084
- }
1085
- throw lastError ?? new Error('delegated push retry exhausted')
1086
- }
1087
-
1088
- #log(fields: Record<string, unknown>): void {
1089
- console.log(
1090
- JSON.stringify({
1091
- event: 'sync_request',
1092
- hostVersion: config.hostVersion,
1093
- engineVersion: engine_version(),
1094
- ...fields,
1095
- })
1096
- )
1097
- }
1098
-
1099
- async #runEffects(effects: DeferredEffect[]): Promise<void> {
1100
- for (const effect of effects) {
1101
- try {
1102
- await effect()
1103
- } catch (error) {
1104
- this.#counters.externalEffectFailures++
1105
- console.error(
1106
- JSON.stringify({
1107
- event: 'sync_external_effect_error',
1108
- hostVersion: config.hostVersion,
1109
- error: errorMessage(error),
1110
- })
1111
- )
1112
- }
1113
- }
1114
- }
1115
-
1116
- #enqueueWake(originClientID: string): Promise<void> {
1117
- this.#wakeOrigins.add(originClientID)
1118
- for (const socket of this.ctx.getWebSockets()) {
1119
- const attachment = socketAttachment(socket)
1120
- if (
1121
- !attachment ||
1122
- attachment.clientID === originClientID ||
1123
- this.#pulling.has(attachment.clientID)
1124
- ) {
1125
- continue
1126
- }
1127
- this.#wakeRecipients.add(socket)
1128
- }
1129
- return this.#scheduleWake()
1130
- }
1131
-
1132
- #scheduleWake(): Promise<void> {
1133
- if (!this.#wakePromise) {
1134
- const queuedAt = performance.now()
1135
- this.#wakePromise = (async () => {
1136
- await scheduler.wait(wakeCoalesceMs)
1137
- const fanoutStarted = performance.now()
1138
- const origins = this.#wakeOrigins
1139
- this.#wakeOrigins = new Set()
1140
- const recipients = this.#wakeRecipients
1141
- this.#wakeRecipients = new Set()
1142
- this.#counters.wakeBatches++
1143
- let sent = 0
1144
- const sockets = this.ctx.getWebSockets()
1145
- for (const socket of recipients) {
1146
- try {
1147
- socket.send('wake')
1148
- sent++
1149
- this.#counters.wakeFrames++
1150
- } catch {
1151
- // A closing hibernating socket disappears from getWebSockets;
1152
- // a race here is advisory and carries no correctness weight.
1153
- }
1154
- }
1155
- console.log(
1156
- JSON.stringify({
1157
- event: 'sync_wake',
1158
- hostVersion: config.hostVersion,
1159
- socketCount: sockets.length,
1160
- originCount: origins.size,
1161
- sent,
1162
- eligibleRecipients: recipients.size,
1163
- coalesceMs: fanoutStarted - queuedAt,
1164
- fanoutMs: performance.now() - fanoutStarted,
1165
- })
1166
- )
1167
- })().finally(() => {
1168
- this.#wakePromise = null
1169
- if (this.#wakeOrigins.size > 0) void this.#scheduleWake()
1170
- })
1171
- }
1172
- return this.#wakePromise
1173
- }
1174
-
1175
- async #pull(
1176
- request: Request,
1177
- claims: NormalizedClaims,
1178
- namespace: string
1179
- ): Promise<Response> {
1180
- this.#counters.pulls++
1181
- this.#engineDb.resetStats()
1182
- const started = performance.now()
1183
- let transactionMs = 0
1184
- let body: Record<string, unknown> | undefined
1185
- try {
1186
- body = await requestObject(request)
1187
- const queryAware =
1188
- this.#queryAwareOverride() ??
1189
- (typeof config.queryAware === 'function'
1190
- ? config.queryAware(claims)
1191
- : (config.queryAware ?? Boolean(config.resolveQuery)))
1192
- const transformVersion = queryAware
1193
- ? typeof config.queryTransformVersion === 'function'
1194
- ? config.queryTransformVersion(claims)
1195
- : (config.queryTransformVersion ?? 0)
1196
- : 0
1197
- if (!Number.isSafeInteger(transformVersion) || transformVersion < 0) {
1198
- throw new TypeError('queryTransformVersion must be a non-negative safe integer')
1199
- }
1200
- const releaseQueryPull =
1201
- queryAware && config.resolveQuery
1202
- ? await this.#acquireQueryPullLock(
1203
- typeof body.clientGroupID === 'string' ? body.clientGroupID : ''
1204
- )
1205
- : null
1206
- let response: Record<string, unknown>
1207
- try {
1208
- if (queryAware && body.queries) {
1209
- const queries = body.queries as {
1210
- version?: unknown
1211
- patch?: unknown
1212
- }
1213
- if (Array.isArray(queries.patch)) {
1214
- const patch = []
1215
- for (const operation of queries.patch) {
1216
- if (!operation || typeof operation !== 'object') {
1217
- patch.push(operation)
1218
- continue
1219
- }
1220
- const op = operation as Record<string, unknown>
1221
- if (op.op === 'put') {
1222
- if (!config.resolveQuery || typeof op.name !== 'string') {
1223
- throw requestError('query put requires a server-resolved named query')
1224
- }
1225
- if (!Array.isArray(op.args)) {
1226
- throw requestError('named query args must be an array')
1227
- }
1228
- const args = op.args as JsonValue[]
1229
- let ast: JsonValue
1230
- try {
1231
- // resolveQuery may be async and needs `env` (a consumer can
1232
- // delegate the transform to its app's real synced-queries
1233
- // endpoint over an app service binding — authenticate runs in the
1234
- // worker isolate, but the query loop runs here in the DO, so the
1235
- // binding must come from the DO's own env, not a shared global).
1236
- ast = await config.resolveQuery(op.name, args, claims, this.env)
1237
- } catch (error) {
1238
- throw requestError(`unknown or unsupported named query: ${op.name}`)
1239
- }
1240
- patch.push({
1241
- op: 'put',
1242
- hash: op.hash,
1243
- ast,
1244
- transformVersion,
1245
- })
1246
- } else patch.push(operation)
1247
- }
1248
- body = { ...body, queries: { ...queries, patch } }
1249
- }
1250
- }
1251
- if (queryAware) {
1252
- body = { ...body, _serverQueryTransformVersion: transformVersion }
1253
- }
1254
- const clientID = typeof body.clientID === 'string' ? body.clientID : ''
1255
- this.#pulling.add(clientID)
1256
- try {
1257
- const txStarted = performance.now()
1258
- const duringFault = this.#takeFault('pull_during_tx')
1259
- response = this.ctx.storage.transactionSync(() => {
1260
- const result = this.#wasm(() =>
1261
- queryAware
1262
- ? engine_handle_query_pull(
1263
- this.#engineDb,
1264
- config.schema,
1265
- this.#retainChanges(),
1266
- body,
1267
- claims.userID
1268
- )
1269
- : engine_handle_pull(
1270
- this.#engineDb,
1271
- config.schema,
1272
- this.#visibility(claims),
1273
- caps,
1274
- this.#retainChanges(),
1275
- body,
1276
- claims.userID
1277
- )
1278
- ) as Record<string, unknown>
1279
- if (duringFault) throw this.#faultError(duringFault, 'pull_during_tx')
1280
- return result
1281
- })
1282
- transactionMs = performance.now() - txStarted
1283
- } finally {
1284
- this.#pulling.delete(clientID)
1285
- }
1286
- } finally {
1287
- releaseQueryPull?.()
1288
- }
1289
- const afterPullFault = this.#takeFault('pull_after_commit')
1290
- if (afterPullFault) throw this.#faultError(afterPullFault, 'pull_after_commit')
1291
- const patch = Array.isArray(response.rowsPatch) ? response.rowsPatch : []
1292
- const queriesBody = body.queries as { patch?: unknown[] } | undefined
1293
- const queryPuts =
1294
- queryAware && Array.isArray(queriesBody?.patch)
1295
- ? queriesBody.patch.filter(
1296
- (entry) => (entry as { op?: unknown } | null)?.op === 'put'
1297
- ).length
1298
- : 0
1299
- this.#counters.queryRecompilations += queryPuts
1300
- const state = this.#engineStateBestEffort()
1301
- this.#log({
1302
- namespaceHash: namespace,
1303
- requestKind: 'pull',
1304
- resultClass: response.unchanged === true ? 'unchanged' : 'success',
1305
- inputCookie: body.cookie ?? null,
1306
- outputCookie: response.cookie ?? null,
1307
- retainedFloor: state?.floor ?? null,
1308
- currentWatermark: state?.watermark ?? null,
1309
- changeRowsScanned: null,
1310
- changeRowsIncluded: null,
1311
- queriesRecomputed: queryPuts,
1312
- rowPuts: patch.filter((entry) => entry?.op === 'put').length,
1313
- rowDeletes: patch.filter((entry) => entry?.op === 'del').length,
1314
- lmidAdvances: 0,
1315
- transactionMs,
1316
- totalMs: performance.now() - started,
1317
- resetReason: null,
1318
- wasmBoundaryCalls: this.#counters.wasmBoundaryCalls,
1319
- sql: this.#engineDb.stats,
1320
- })
1321
- return json(response)
1322
- } catch (error) {
1323
- const status = statusOf(error)
1324
- if (status === 409) this.#counters.resets++
1325
- if (status === 500) this.#counters.invariantFailures++
1326
- const state = this.#engineStateBestEffort()
1327
- this.#log({
1328
- namespaceHash: namespace,
1329
- requestKind: 'pull',
1330
- resultClass: status === 409 ? 'reset' : 'error',
1331
- inputCookie: body?.cookie ?? null,
1332
- outputCookie: null,
1333
- retainedFloor: state?.floor ?? null,
1334
- currentWatermark: state?.watermark ?? null,
1335
- changeRowsScanned: null,
1336
- changeRowsIncluded: 0,
1337
- queriesRecomputed: 0,
1338
- rowPuts: 0,
1339
- rowDeletes: 0,
1340
- lmidAdvances: 0,
1341
- transactionMs,
1342
- totalMs: performance.now() - started,
1343
- resetReason: status === 409 ? errorMessage(error) : null,
1344
- })
1345
- return json({ error: errorMessage(error) }, status)
1346
- }
1347
- }
1348
-
1349
- async #push(
1350
- request: Request,
1351
- claims: NormalizedClaims,
1352
- namespace: string,
1353
- upstreamPath: string | null
1354
- ): Promise<Response> {
1355
- this.#counters.pushes++
1356
- this.#engineDb.resetStats()
1357
- const started = performance.now()
1358
- let transactionMs = 0
1359
- let lmidAdvances = 0
1360
- let resultClass = 'success'
1361
- if (!this.#writerEnabled()) {
1362
- // Workerd requires the request stream to be consumed before the DO
1363
- // returns a response. Discard it without parsing or logging payloads.
1364
- await request.arrayBuffer()
1365
- const state = this.#engineStateBestEffort()
1366
- this.#log({
1367
- namespaceHash: namespace,
1368
- requestKind: 'push',
1369
- resultClass: 'writer_disabled',
1370
- inputCookie: null,
1371
- outputCookie: null,
1372
- retainedFloor: state?.floor ?? null,
1373
- currentWatermark: state?.watermark ?? null,
1374
- changeRowsScanned: 0,
1375
- changeRowsIncluded: 0,
1376
- queriesRecomputed: 0,
1377
- rowPuts: 0,
1378
- rowDeletes: 0,
1379
- lmidAdvances: 0,
1380
- transactionMs: 0,
1381
- totalMs: performance.now() - started,
1382
- resetReason: 'writer disabled by operator',
1383
- wasmBoundaryCalls: this.#counters.wasmBoundaryCalls,
1384
- sql: this.#engineDb.stats,
1385
- })
1386
- return json({ error: 'writer disabled by operator' }, 503)
1387
- }
1388
- if (config.mutateUrl) {
1389
- try {
1390
- const bytes = await request.arrayBuffer()
1391
- const body = JSON.parse(new TextDecoder().decode(bytes)) as Record<
1392
- string,
1393
- unknown
1394
- >
1395
- const plan = this.#wasm(() => engine_push_validate(body)) as PushPlan
1396
- if (plan.kind === 'respond') return json(plan.response)
1397
-
1398
- const endpoint = new URL(
1399
- `${upstreamPath ?? ''}${config.mutateUrl}`,
1400
- config.mutateOrigin ?? 'https://upstream.invalid'
1401
- )
1402
- const headers = new Headers(request.headers)
1403
- headers.delete(CLAIMS_HEADER)
1404
- headers.delete(NAMESPACE_HEADER)
1405
- headers.delete(UPSTREAM_PATH_HEADER)
1406
- headers.set('host', endpoint.host)
1407
- const upstreamResponse = await this.#fetchDelegatedPush(
1408
- endpoint,
1409
- headers,
1410
- bytes,
1411
- this.#engineState().upstreamWatermark === '0'
1412
- )
1413
- if (!upstreamResponse.ok) {
1414
- return new Response(upstreamResponse.body, upstreamResponse)
1415
- }
1416
- const upstreamBody = (await upstreamResponse.json()) as DelegatedPushBody
1417
- const delegatedResponse = upstreamBody.pushResponse ?? upstreamBody
1418
- if (isStructuredPushFailed(delegatedResponse)) {
1419
- // PushFailed is a successful protocol response describing an
1420
- // application-level failure. There are intentionally no mutation
1421
- // acknowledgements to finalize in the host; preserve the body so
1422
- // the Zero client can apply its retry/error policy.
1423
- return json({ pushResponse: delegatedResponse })
1424
- }
1425
- const acknowledged =
1426
- typeof upstreamBody.pushResponse === 'object' &&
1427
- upstreamBody.pushResponse !== null &&
1428
- 'mutations' in upstreamBody.pushResponse
1429
- ? upstreamBody.pushResponse.mutations
1430
- : upstreamBody.mutations
1431
- if (!Array.isArray(acknowledged)) {
1432
- throw new Error('delegated push returned no mutation results')
1433
- }
1434
- for (const mutation of plan.mutations) {
1435
- const ack = acknowledged.some(
1436
- (result) =>
1437
- result.id?.clientID === mutation.clientID &&
1438
- String(result.id?.id) === mutation.id
1439
- )
1440
- if (!ack) continue
1441
- this.ctx.storage.transactionSync(() => {
1442
- const decision = this.#wasm(() =>
1443
- engine_preflight(
1444
- this.#engineDb,
1445
- plan.clientGroupID,
1446
- mutation.clientID,
1447
- mutation.id,
1448
- claims.userID
1449
- )
1450
- ) as Preflight
1451
- if (decision.kind === 'applied') {
1452
- this.#wasm(() =>
1453
- engine_finalize(
1454
- this.#engineDb,
1455
- plan.clientGroupID,
1456
- mutation.clientID,
1457
- mutation.id
1458
- )
1459
- )
1460
- lmidAdvances++
1461
- }
1462
- })
1463
- }
1464
- if (lmidAdvances > 0) {
1465
- this.ctx.storage.transactionSync(() =>
1466
- this.#wasm(() => engine_prune(this.#engineDb, this.#retainChanges()))
1467
- )
1468
- }
1469
- await this.#ingest(upstreamPath)
1470
- return json({ pushResponse: delegatedResponse })
1471
- } catch (error) {
1472
- const status = statusOf(error)
1473
- return json(errorBody(error), status)
1474
- }
1475
- }
1476
- try {
1477
- const body = await requestObject(request)
1478
- const beforeMutationFault = this.#takeFault('push_before_mutation')
1479
- if (beforeMutationFault)
1480
- throw this.#faultError(beforeMutationFault, 'push_before_mutation')
1481
- const plan = this.#wasm(() => engine_push_validate(body)) as PushPlan
1482
- if (plan.kind === 'respond') return json(plan.response)
1483
-
1484
- const results: MutationResult[] = []
1485
- for (const mutation of plan.mutations) {
1486
- const deferred: DeferredEffect[] = []
1487
- try {
1488
- const txStarted = performance.now()
1489
- // consume the before-commit fault OUTSIDE the transaction it is
1490
- // about to abort: taken inside, the control-table delete rolls
1491
- // back with the abort and the fault re-fires on every retry
1492
- // instead of being one-shot.
1493
- const beforeCommitFault = this.#takeFault('push_after_write_before_commit')
1494
- const preflight = await this.ctx.storage.transaction(async () => {
1495
- // Storage transactions may retry their closure. Never carry a
1496
- // deferred effect from an abandoned attempt into the commit.
1497
- deferred.length = 0
1498
- const decision = this.#wasm(() =>
1499
- engine_preflight(
1500
- this.#engineDb,
1501
- plan.clientGroupID,
1502
- mutation.clientID,
1503
- mutation.id,
1504
- claims.userID
1505
- )
1506
- ) as Preflight
1507
- if (decision.kind === 'replay') return decision
1508
- const mutator = config.mutators?.[mutation.name]
1509
- if (!mutator) throw new Error(`unknown mutator: ${mutation.name}`)
1510
- await mutator(this.#mutatorSql, mutation.args[0] ?? null, {
1511
- claims,
1512
- clientID: mutation.clientID,
1513
- mutationID: mutation.id,
1514
- defer(effect) {
1515
- deferred.push(effect)
1516
- },
1517
- })
1518
- if (beforeCommitFault)
1519
- throw this.#faultError(
1520
- beforeCommitFault,
1521
- 'push_after_write_before_commit'
1522
- )
1523
- this.#wasm(() =>
1524
- engine_finalize(
1525
- this.#engineDb,
1526
- plan.clientGroupID,
1527
- mutation.clientID,
1528
- mutation.id
1529
- )
1530
- )
1531
- return decision
1532
- })
1533
- transactionMs += performance.now() - txStarted
1534
-
1535
- if ((preflight as Preflight).kind === 'replay') {
1536
- const expected = (preflight as Extract<Preflight, { kind: 'replay' }>)
1537
- .expected
1538
- results.push({
1539
- clientID: mutation.clientID,
1540
- id: mutation.id,
1541
- result: {
1542
- error: 'alreadyProcessed',
1543
- details: `Ignoring mutation from ${mutation.clientID} with ID ${mutation.id} as it was already processed. Expected: ${expected}`,
1544
- },
1545
- })
1546
- continue
1547
- }
1548
-
1549
- lmidAdvances++
1550
- results.push({
1551
- clientID: mutation.clientID,
1552
- id: mutation.id,
1553
- result: {},
1554
- })
1555
- // Keep the advisory fan-out off the push response's critical path.
1556
- // waitUntil anchors the coalescing timer across request completion,
1557
- // while the next serialized client push can join the same batch.
1558
- this.ctx.waitUntil(this.#enqueueWake(mutation.clientID))
1559
- await this.#runEffects(deferred)
1560
- } catch (error) {
1561
- const isAppError = error instanceof MutationApplicationError
1562
- if (!isAppError) throw error
1563
- this.#counters.applicationErrors++
1564
- resultClass = 'application_error'
1565
- const appError = error as Error & { details?: string }
1566
- const txStarted = performance.now()
1567
- await this.ctx.storage.transaction(async () => {
1568
- this.#wasm(() =>
1569
- engine_record_app_error(
1570
- this.#engineDb,
1571
- plan.clientGroupID,
1572
- mutation.clientID,
1573
- mutation.id,
1574
- claims.userID
1575
- )
1576
- )
1577
- })
1578
- transactionMs += performance.now() - txStarted
1579
- lmidAdvances++
1580
- results.push({
1581
- clientID: mutation.clientID,
1582
- id: mutation.id,
1583
- result: {
1584
- error: 'app',
1585
- message: appError.message,
1586
- details: appError.details ?? appError.message,
1587
- },
1588
- })
1589
- this.ctx.waitUntil(this.#enqueueWake(mutation.clientID))
1590
- }
1591
- }
1592
-
1593
- if (plan.mutations.length > 0) {
1594
- const txStarted = performance.now()
1595
- await this.ctx.storage.transaction(async () => {
1596
- this.#wasm(() => engine_prune(this.#engineDb, this.#retainChanges()))
1597
- })
1598
- transactionMs += performance.now() - txStarted
1599
- this.#counters.retentionRuns++
1600
- }
1601
-
1602
- const afterCommitFault = this.#takeFault('push_after_commit_before_response')
1603
- if (afterCommitFault)
1604
- throw this.#faultError(afterCommitFault, 'push_after_commit_before_response')
1605
-
1606
- const response = this.#wasm(() => engine_assemble_push_response(results))
1607
- const state = this.#engineStateBestEffort()
1608
- this.#log({
1609
- namespaceHash: namespace,
1610
- requestKind: 'push',
1611
- resultClass,
1612
- inputCookie: null,
1613
- outputCookie: null,
1614
- retainedFloor: state?.floor ?? null,
1615
- currentWatermark: state?.watermark ?? null,
1616
- changeRowsScanned: 0,
1617
- changeRowsIncluded: 0,
1618
- queriesRecomputed: 0,
1619
- rowPuts: 0,
1620
- rowDeletes: 0,
1621
- lmidAdvances,
1622
- transactionMs,
1623
- totalMs: performance.now() - started,
1624
- resetReason: null,
1625
- wasmBoundaryCalls: this.#counters.wasmBoundaryCalls,
1626
- sql: this.#engineDb.stats,
1627
- })
1628
- if (this.#dropNextPushResponse) {
1629
- this.#dropNextPushResponse = false
1630
- return json({ error: 'intentionally dropped push response' }, 503)
1631
- }
1632
- return json(response)
1633
- } catch (error) {
1634
- const status = statusOf(error)
1635
- if (status === 500) this.#counters.invariantFailures++
1636
- const state = this.#engineStateBestEffort()
1637
- this.#log({
1638
- namespaceHash: namespace,
1639
- requestKind: 'push',
1640
- resultClass: 'error',
1641
- inputCookie: null,
1642
- outputCookie: null,
1643
- retainedFloor: state?.floor ?? null,
1644
- currentWatermark: state?.watermark ?? null,
1645
- changeRowsScanned: 0,
1646
- changeRowsIncluded: 0,
1647
- queriesRecomputed: 0,
1648
- rowPuts: 0,
1649
- rowDeletes: 0,
1650
- lmidAdvances,
1651
- transactionMs,
1652
- totalMs: performance.now() - started,
1653
- resetReason: null,
1654
- })
1655
- return json({ error: errorMessage(error) }, status)
1656
- }
1657
- }
1658
-
1659
- #wake(request: Request): Response {
1660
- if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
1661
- return json({ error: 'websocket upgrade required' }, 426)
1662
- }
1663
- const clientID = new URL(request.url).searchParams.get('clientID')
1664
- if (!clientID) return json({ error: 'clientID is required' }, 400)
1665
- const pair = new WebSocketPair()
1666
- const [client, server] = Object.values(pair)
1667
- server.serializeAttachment({ clientID } satisfies SocketAttachment)
1668
- this.ctx.acceptWebSocket(server, [`client:${clientID}`])
1669
- // The alarm is only a safety net for an actively connected consumer.
1670
- // A namespace with no wake socket has nobody to notify; its next pull or
1671
- // push ingests synchronously. Arming from construction made every
1672
- // namespace poll upstream forever after its first request, even across
1673
- // DO eviction, producing a permanent rows-written floor at zero traffic.
1674
- this.#armUpstreamAlarm()
1675
- return new Response(null, { status: 101, webSocket: client })
1676
- }
1677
-
1678
- #admin(
1679
- route: string,
1680
- request: Request,
1681
- upstreamPath: string | null
1682
- ): Promise<Response> | Response {
1683
- if (route === '/admin/health') return json({ ok: true })
1684
- if (route === '/admin/upstream-write-budget' && request.method === 'GET')
1685
- return this.#upstreamWriteBudgetStatus()
1686
- if (route === '/admin/status') {
1687
- const heap = (
1688
- performance as Performance & {
1689
- memory?: {
1690
- usedJSHeapSize: number
1691
- totalJSHeapSize: number
1692
- jsHeapSizeLimit: number
1693
- }
1694
- }
1695
- ).memory
1696
- return this.ctx.storage.getAlarm().then((upstreamAlarmAt) =>
1697
- json({
1698
- bootID: this.#bootID,
1699
- idleTeardownMs,
1700
- hibernations: this.#hibernations,
1701
- databaseSizeBytes: this.ctx.storage.sql.databaseSize,
1702
- connectedWakeSockets: this.ctx.getWebSockets().length,
1703
- upstreamAlarmAt,
1704
- writerEnabled: this.#writerEnabled(),
1705
- wasmMemoryBytes: engine_memory_bytes(),
1706
- heapUsedBytes: heap?.usedJSHeapSize ?? null,
1707
- heapTotalBytes: heap?.totalJSHeapSize ?? null,
1708
- heapLimitBytes: heap?.jsHeapSizeLimit ?? null,
1709
- engine: this.#engineStateBestEffort(),
1710
- counters: this.#counters,
1711
- ingestBreaker: this.#ingestBreaker.status(),
1712
- })
1713
- )
1714
- }
1715
- if (route === '/admin/sql') {
1716
- return request.json().then((body) => {
1717
- const { query } = body as { query?: string }
1718
- if (typeof query !== 'string') return json({ error: 'query is required' }, 400)
1719
- try {
1720
- return json({ rows: this.#directSql.query(query) })
1721
- } catch (error) {
1722
- if (
1723
- error instanceof TypeError &&
1724
- error.message === 'transaction SQL is host-owned and forbidden'
1725
- ) {
1726
- return json({ error: error.message }, 400)
1727
- }
1728
- throw error
1729
- }
1730
- })
1731
- }
1732
- if (route === '/admin/invalidate') {
1733
- this.ctx.storage.transactionSync(() =>
1734
- this.#wasm(() => engine_invalidate(this.#engineDb))
1735
- )
1736
- return json({ ok: true, engine: this.#engineState() })
1737
- }
1738
- if (route === '/admin/resnapshot') {
1739
- if (request.method !== 'POST') {
1740
- return json({ error: 'method not allowed' }, 405)
1741
- }
1742
- return (async () => {
1743
- try {
1744
- const beforeUpstreamWatermark = this.#engineState().upstreamWatermark
1745
- const applied = await this.#ingest(upstreamPath, true)
1746
- const engine = this.#engineState()
1747
- return json({
1748
- ok: true,
1749
- applied,
1750
- beforeUpstreamWatermark,
1751
- afterUpstreamWatermark: engine.upstreamWatermark,
1752
- engine,
1753
- })
1754
- } catch (error) {
1755
- return json(errorBody(error), statusOf(error))
1756
- }
1757
- })()
1758
- }
1759
- if (route === '/admin/drop-next-push-response') {
1760
- this.#dropNextPushResponse = true
1761
- return json({ ok: true })
1762
- }
1763
- if (route === '/admin/restart') {
1764
- this.ctx.abort('admin requested durable object restart')
1765
- return json({ ok: true, bootID: this.#bootID })
1766
- }
1767
- if (route === '/admin/visibility') {
1768
- return request.json().then((body) => {
1769
- const enabled = Boolean((body as { enabled?: unknown }).enabled)
1770
- this.#controlSet('visibilityEnabled', enabled ? '1' : '0')
1771
- return json({ ok: true, enabled })
1772
- })
1773
- }
1774
- if (route === '/admin/query-aware') {
1775
- return request.json().then((body) => {
1776
- const enabled = Boolean((body as { enabled?: unknown }).enabled)
1777
- this.#controlSet('queryAwareOverride', enabled ? '1' : '0')
1778
- return json({ ok: true, enabled })
1779
- })
1780
- }
1781
- if (route === '/admin/retention') {
1782
- return request
1783
- .json()
1784
- .catch(() => ({}))
1785
- .then((body) => {
1786
- const value = Number((body as { retainChanges?: unknown }).retainChanges)
1787
- if (!Number.isSafeInteger(value) || value < 0)
1788
- return json({ error: 'invalid retainChanges' }, 400)
1789
- this.#controlSet('retainChanges', String(value))
1790
- return json({ ok: true, retainChanges: value })
1791
- })
1792
- }
1793
- if (route === '/admin/writer') {
1794
- if (request.method === 'GET')
1795
- return json({ writerEnabled: this.#writerEnabled() })
1796
- return request
1797
- .json()
1798
- .catch(() => ({}))
1799
- .then((body) => {
1800
- const enabled = (body as { enabled?: unknown }).enabled
1801
- if (typeof enabled !== 'boolean')
1802
- return json({ error: 'enabled must be a boolean' }, 400)
1803
- this.#controlSet('writerEnabled', enabled ? '1' : '0')
1804
- return json({ ok: true, writerEnabled: enabled })
1805
- })
1806
- }
1807
- if (route === '/admin/ingest-breaker') {
1808
- if (request.method === 'GET') return json(this.#ingestBreaker.status())
1809
- this.#ingestBreaker.reopen()
1810
- this.#controlDelete(
1811
- 'ingestBreakerReason',
1812
- 'ingestBreakerRetryAt',
1813
- 'ingestBreakerTrips'
1814
- )
1815
- console.log(JSON.stringify({ event: 'sync_upstream_ingest_breaker_reopened' }))
1816
- return json({ ok: true, ...this.#ingestBreaker.status() })
1817
- }
1818
- if (route === '/admin/fault') {
1819
- return request
1820
- .json()
1821
- .catch(() => ({}))
1822
- .then((body) => {
1823
- const value = body as {
1824
- clear?: unknown
1825
- point?: unknown
1826
- kind?: unknown
1827
- }
1828
- if (value.clear === true) {
1829
- this.#directSql.exec(
1830
- "DELETE FROM _zsync_host_control WHERE key IN ('faultPoint', 'faultKind')"
1831
- )
1832
- return json({ ok: true, armed: null })
1833
- }
1834
- const points: FaultPoint[] = [
1835
- 'push_before_mutation',
1836
- 'push_after_write_before_commit',
1837
- 'push_after_commit_before_response',
1838
- 'pull_during_tx',
1839
- 'pull_after_commit',
1840
- ]
1841
- if (!points.includes(value.point as FaultPoint))
1842
- return json({ error: 'invalid fault point' }, 400)
1843
- if (value.kind !== 'error' && value.kind !== 'quota')
1844
- return json({ error: 'invalid fault kind' }, 400)
1845
- this.#controlSet('faultPoint', value.point as string)
1846
- this.#controlSet('faultKind', value.kind)
1847
- return json({
1848
- ok: true,
1849
- armed: { point: value.point, kind: value.kind },
1850
- })
1851
- })
1852
- }
1853
- return json({ error: 'not found' }, 404)
1854
- }
1855
-
1856
- async fetch(request: Request): Promise<Response> {
1857
- this.#simulateIdleTeardown(Date.now())
1858
- const route = routeAfterNamespace(new URL(request.url).pathname)
1859
- const namespace = request.headers.get(NAMESPACE_HEADER) ?? 'unknown'
1860
- const upstreamPath = this.#rememberUpstreamPath(request)
1861
- if (route.startsWith('/admin/')) return this.#admin(route, request, upstreamPath)
1862
-
1863
- if (route === '/wake' && request.method === 'GET') return this.#wake(request)
1864
- if (route === '/notify' && request.method === 'POST') {
1865
- try {
1866
- const applied = await this.#ingest(upstreamPath)
1867
- return json({ ok: true, applied })
1868
- } catch (error) {
1869
- return json(errorBody(error), statusOf(error))
1870
- }
1871
- }
1872
-
1873
- const claims = claimsFromRequest(request)
1874
- if (!claims) return json({ error: 'missing normalized claims' }, 401)
1875
- if (route === '/pull' && request.method === 'POST') {
1876
- try {
1877
- await this.#ingest(upstreamPath)
1878
- } catch (error) {
1879
- // Workerd requires a forwarded request body to be consumed even when
1880
- // ingest fails before the pull handler parses it.
1881
- await request.arrayBuffer()
1882
- return json(errorBody(error), statusOf(error))
1883
- }
1884
- return this.#pull(request, claims, namespace)
1885
- }
1886
- if (route === '/push' && request.method === 'POST') {
1887
- // A push may be the first request for a fresh namespace. DATA's
1888
- // /changes call is also its schema-provisioning barrier, so complete it
1889
- // before delegating the mutation to APP. Pull already enforces this
1890
- // ordering above; skipping it here let APP race half-created tables and
1891
- // surface a terminal, bodyless 500 to Zero.
1892
- try {
1893
- await this.#ingest(upstreamPath)
1894
- } catch (error) {
1895
- await request.arrayBuffer()
1896
- return json(errorBody(error), statusOf(error))
1897
- }
1898
- return this.#push(request, claims, namespace, upstreamPath)
1899
- }
1900
- return json({ error: 'not found' }, 404)
1901
- }
1902
-
1903
- async alarm(): Promise<void> {
1904
- if (this.ctx.getWebSockets().length === 0) return
1905
- try {
1906
- await this.#ingest()
1907
- } catch (error) {
1908
- console.error(
1909
- JSON.stringify({
1910
- event: 'sync_upstream_ingest_error',
1911
- status: statusOf(error),
1912
- error: errorMessage(error),
1913
- })
1914
- )
1915
- } finally {
1916
- if (this.ctx.getWebSockets().length > 0) {
1917
- const retryAfterMs = this.#ingestBreaker.status().retryAfterMs
1918
- await this.ctx.storage.setAlarm(
1919
- Date.now() + Math.max(upstreamIntervalMs, retryAfterMs)
1920
- )
1921
- }
1922
- }
1923
- }
1924
-
1925
- webSocketMessage(socket: WebSocket, message: string | ArrayBuffer): void {
1926
- if (message === 'ping') socket.send('pong')
1927
- }
1928
-
1929
- webSocketClose(
1930
- socket: WebSocket,
1931
- code: number,
1932
- reason: string,
1933
- _wasClean: boolean
1934
- ): void {
1935
- // The peer already closed, so echo the close to release the socket — but
1936
- // WebSocket.close() rejects reserved/absent codes (1005 "no status", 1006
1937
- // abnormal, 1015) with InvalidAccessError, and a real browser routinely
1938
- // closes with 1001/1005. An uncaught throw here aborts the DO, so only
1939
- // echo an application-permitted code and otherwise close cleanly.
1940
- const echoable = code === 1000 || (code >= 3000 && code <= 4999)
1941
- socketCloseQuietly(socket, echoable ? code : 1000, echoable ? reason : '')
1942
- }
1943
-
1944
- webSocketError(socket: WebSocket, _error: unknown): void {
1945
- socketCloseQuietly(socket, 1011, 'wake socket error')
1946
- }
1947
- }
1948
- }