orez-sync-cf-host 0.5.19 → 0.5.21

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 (52) hide show
  1. package/README.md +49 -6
  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_bg.wasm +0 -0
  7. package/dist/host.d.ts +12 -0
  8. package/dist/host.js +1512 -0
  9. package/dist/index.d.ts +8 -0
  10. package/dist/index.js +4 -0
  11. package/dist/mutation-error.d.ts +9 -0
  12. package/dist/mutation-error.js +23 -0
  13. package/dist/node-wasm-loader.d.ts +1 -0
  14. package/dist/node-wasm-loader.js +22 -0
  15. package/dist/post-commit.d.ts +13 -0
  16. package/dist/post-commit.js +31 -0
  17. package/dist/query-compiler.d.ts +4 -0
  18. package/dist/query-compiler.js +4 -0
  19. package/dist/sql-storage-adapter.d.ts +69 -0
  20. package/dist/sql-storage-adapter.js +194 -0
  21. package/dist/transaction-query.d.ts +72 -0
  22. package/dist/transaction-query.js +379 -0
  23. package/dist/types.d.ts +159 -0
  24. package/dist/types.js +5 -0
  25. package/dist/vite-wasm-loader.d.ts +3 -0
  26. package/dist/vite-wasm-loader.js +24 -0
  27. package/dist/wasm.d.ts +1 -0
  28. package/dist/wasm.js +7 -0
  29. package/dist/write-safeguards.d.ts +53 -0
  30. package/dist/write-safeguards.js +186 -0
  31. package/package.json +59 -19
  32. package/src/bun-wasm-loader.ts +0 -16
  33. package/src/config.ts +0 -102
  34. package/src/harness-config.ts +0 -487
  35. package/src/harness-worker.ts +0 -33
  36. package/src/host.ts +0 -1958
  37. package/src/index.ts +0 -47
  38. package/src/ingest-harness-worker.ts +0 -535
  39. package/src/mutation-error.ts +0 -28
  40. package/src/platform-probe-worker.ts +0 -418
  41. package/src/post-commit.ts +0 -40
  42. package/src/query-compiler.ts +0 -16
  43. package/src/sql-storage-adapter.ts +0 -254
  44. package/src/transaction-query.ts +0 -494
  45. package/src/types.ts +0 -195
  46. package/src/wasm-platform.ts +0 -10
  47. package/src/wasm.ts +0 -34
  48. package/src/write-safeguards.ts +0 -248
  49. /package/{src → dist}/generated/package.json +0 -0
  50. /package/{src → dist}/generated/sync_wasm.d.ts +0 -0
  51. /package/{src → dist}/generated/sync_wasm.js +0 -0
  52. /package/{src → dist}/generated/sync_wasm_bg.wasm.d.ts +0 -0
package/src/host.ts DELETED
@@ -1,1958 +0,0 @@
1
- import { DurableObject } from 'cloudflare:workers'
2
-
3
- import { validatePullCaps, validateSyncHostConfig } from './config.js'
4
- import { createPostCommitEffects } from './post-commit.js'
5
- import { createQueryCompiler } from './query-compiler.js'
6
- import {
7
- decodeSqlParams,
8
- SqlStorageDirect,
9
- SqlStorageMutatorTransaction,
10
- SqlStorageSyncDb,
11
- } from './sql-storage-adapter.js'
12
- import { isMutationApplicationError } from './types.js'
13
- import {
14
- engine_apply_snapshot_changes,
15
- engine_apply_snapshot_page,
16
- engine_apply_upstream,
17
- engine_assemble_push_response,
18
- engine_begin_snapshot_generation,
19
- engine_finalize,
20
- engine_finalize_snapshot_generation,
21
- engine_handle_pull,
22
- engine_handle_query_pull,
23
- engine_init_query_schema,
24
- engine_init_schema,
25
- engine_invalidate,
26
- engine_memory_bytes,
27
- engine_preflight,
28
- engine_prune,
29
- engine_push_validate,
30
- engine_read_snapshot_progress,
31
- engine_record_app_error,
32
- engine_state,
33
- engine_version,
34
- } from './wasm.js'
35
- import {
36
- IngestBreakerError,
37
- IngestCircuitBreaker,
38
- retryDelayMs,
39
- shouldRetryDelegatedPush,
40
- } from './write-safeguards.js'
41
-
42
- import type {
43
- JsonValue,
44
- NormalizedClaims,
45
- PullCaps,
46
- SyncHostConfig,
47
- SyncHostEnv,
48
- } from './types.js'
49
-
50
- const CLAIMS_HEADER = 'x-orez-sync-claims'
51
- const NAMESPACE_HEADER = 'x-orez-sync-namespace'
52
- const UPSTREAM_PATH_HEADER = 'x-orez-sync-upstream-path'
53
- const DEFAULT_SNAPSHOT_PAGE_ROWS = 2_000
54
- const MIN_SNAPSHOT_PAGE_ROWS = 100
55
- const DEFAULT_CAPS: PullCaps = {
56
- maxChangeRows: 10_000,
57
- maxChangeBytes: 2_000_000,
58
- }
59
-
60
- type PushMutation = {
61
- id: string
62
- clientID: string
63
- name: string
64
- args: JsonValue[]
65
- }
66
-
67
- type PushPlan =
68
- | { kind: 'respond'; response: unknown }
69
- | { kind: 'process'; clientGroupID: string; mutations: PushMutation[] }
70
-
71
- type Preflight = { kind: 'applied' } | { kind: 'replay'; expected: string }
72
-
73
- type MutationResult = {
74
- clientID: string
75
- id: string
76
- result: Record<string, unknown>
77
- }
78
-
79
- type DelegatedMutationResult = { id?: { clientID?: unknown; id?: unknown } }
80
- type DelegatedPushBody = {
81
- mutations?: DelegatedMutationResult[]
82
- pushResponse?: unknown
83
- [key: string]: unknown
84
- }
85
-
86
- type EngineState = { watermark: string; floor: string; upstreamWatermark: string }
87
- type UpstreamBatch = {
88
- watermark: number
89
- changes: Array<{
90
- watermark: number
91
- tableName: string
92
- op: string
93
- rowData: Record<string, unknown> | null
94
- oldData: Record<string, unknown> | null
95
- }>
96
- }
97
- type ApplyUpstreamResult = {
98
- watermark: number | string
99
- applied: number
100
- caughtUp: boolean
101
- }
102
- type SnapshotProgress = {
103
- generation: string
104
- startWatermark: string
105
- table: string | null
106
- cursor: string | null
107
- state: 'paging' | 'catching_up'
108
- catchupWatermark: string
109
- }
110
- type SnapshotPage = {
111
- watermark: number
112
- rows: Record<string, unknown>[]
113
- nextCursor: string | null
114
- }
115
- type SocketAttachment = { clientID: string }
116
- type FaultPoint =
117
- | 'push_before_mutation'
118
- | 'push_after_write_before_commit'
119
- | 'push_after_commit_before_response'
120
- | 'pull_during_tx'
121
- | 'pull_after_commit'
122
- type FaultKind = 'error' | 'quota'
123
-
124
- type Counters = {
125
- pulls: number
126
- pushes: number
127
- resets: number
128
- applicationErrors: number
129
- invariantFailures: number
130
- retentionRuns: number
131
- queryRecompilations: number
132
- wasmBoundaryCalls: number
133
- wakeFrames: number
134
- wakeBatches: number
135
- externalEffectFailures: number
136
- }
137
-
138
- function freshCounters(): Counters {
139
- return {
140
- pulls: 0,
141
- pushes: 0,
142
- resets: 0,
143
- applicationErrors: 0,
144
- invariantFailures: 0,
145
- retentionRuns: 0,
146
- queryRecompilations: 0,
147
- wasmBoundaryCalls: 0,
148
- wakeFrames: 0,
149
- wakeBatches: 0,
150
- externalEffectFailures: 0,
151
- }
152
- }
153
-
154
- function json(value: unknown, status = 200): Response {
155
- return Response.json(value, {
156
- status,
157
- headers: { 'cache-control': 'no-store' },
158
- })
159
- }
160
-
161
- function isStructuredPushFailed(value: unknown): value is Record<string, unknown> {
162
- if (typeof value !== 'object' || value === null) return false
163
- const body = value as Record<string, unknown>
164
- return (
165
- body.kind === 'PushFailed' &&
166
- typeof body.origin === 'string' &&
167
- typeof body.reason === 'string' &&
168
- typeof body.message === 'string' &&
169
- Array.isArray(body.mutationIDs)
170
- )
171
- }
172
-
173
- function statusOf(error: unknown): number {
174
- if (typeof error === 'object' && error !== null && 'status' in error) {
175
- const status = Number(error.status)
176
- if (Number.isInteger(status) && status >= 400 && status <= 599) return status
177
- }
178
- return 500
179
- }
180
-
181
- function errorMessage(error: unknown): string {
182
- return error instanceof Error ? error.message : String(error)
183
- }
184
-
185
- function errorBody(error: unknown): Record<string, unknown> {
186
- if (error instanceof IngestBreakerError) {
187
- return {
188
- error: error.error,
189
- windowRows: error.windowRows,
190
- budget: error.budget,
191
- retryAfterMs: error.retryAfterMs,
192
- }
193
- }
194
- return { error: errorMessage(error) }
195
- }
196
-
197
- function requestError(message: string, status = 400): Error & { status: number } {
198
- return Object.assign(new Error(message), { status })
199
- }
200
-
201
- async function requestJson(request: Request): Promise<unknown> {
202
- try {
203
- return await request.json()
204
- } catch {
205
- throw requestError('invalid JSON request body')
206
- }
207
- }
208
-
209
- async function requestObject(request: Request): Promise<Record<string, unknown>> {
210
- const value = await requestJson(request)
211
- if (!value || typeof value !== 'object' || Array.isArray(value))
212
- throw requestError('request body must be a JSON object')
213
- return value as Record<string, unknown>
214
- }
215
-
216
- function routeAfterNamespace(pathname: string): string {
217
- const [, , ...parts] = pathname.split('/')
218
- return `/${parts.join('/')}`
219
- }
220
-
221
- function claimsFromRequest(request: Request): NormalizedClaims | null {
222
- const encoded = request.headers.get(CLAIMS_HEADER)
223
- if (!encoded) return null
224
- try {
225
- const value = JSON.parse(decodeURIComponent(encoded)) as NormalizedClaims
226
- return value && typeof value.userID === 'string' && value.userID.length > 0
227
- ? value
228
- : null
229
- } catch {
230
- return null
231
- }
232
- }
233
-
234
- async function namespaceHash(namespace: string): Promise<string> {
235
- const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(namespace))
236
- return Array.from(new Uint8Array(bytes).slice(0, 8), (byte) =>
237
- byte.toString(16).padStart(2, '0')
238
- ).join('')
239
- }
240
-
241
- function socketAttachment(socket: WebSocket): SocketAttachment | null {
242
- const value = socket.deserializeAttachment() as SocketAttachment | null
243
- return value && typeof value.clientID === 'string' ? value : null
244
- }
245
-
246
- // Closing a socket that is already closed/closing throws, and a throw inside a
247
- // hibernatable WebSocket handler aborts the DO. Swallow it: the socket is going
248
- // away regardless.
249
- function socketCloseQuietly(socket: WebSocket, code: number, reason: string): void {
250
- try {
251
- socket.close(code, reason)
252
- } catch {
253
- // already closing/closed, or workerd rejected the code — nothing to do
254
- }
255
- }
256
-
257
- /**
258
- * Create the consumer-facing Worker router. Authentication happens here; the
259
- * Durable Object receives only normalized claims over a binding-private header.
260
- */
261
- export function createSyncWorker<Env extends SyncHostEnv>(
262
- config: SyncHostConfig<Env>
263
- ): ExportedHandler<Env> {
264
- validateSyncHostConfig(config)
265
- return {
266
- async fetch(request, env): Promise<Response> {
267
- const namespace = config.namespace(request)
268
- if (!namespace) return new Response('orez sync-cf-host', { status: 200 })
269
-
270
- const route = routeAfterNamespace(new URL(request.url).pathname)
271
- const isAdmin = route.startsWith('/admin/')
272
- if (route === '/wake') {
273
- if (!(await config.authorizeWake(request, env))) {
274
- return json({ error: 'missing wake capability' }, 401)
275
- }
276
- } else if (route === '/notify') {
277
- if (!(await config.authorizeNotify(request, env))) {
278
- return json({ error: 'forbidden' }, 403)
279
- }
280
- } else if (isAdmin) {
281
- const authorized = config.authorizeAdmin
282
- ? await config.authorizeAdmin(request, env)
283
- : Boolean(env.ADMIN_KEY) && request.headers.get('x-admin-key') === env.ADMIN_KEY
284
- if (!authorized) return json({ error: 'forbidden' }, 403)
285
- }
286
-
287
- const headers = new Headers(request.headers)
288
- headers.delete(CLAIMS_HEADER)
289
- headers.delete(NAMESPACE_HEADER)
290
- headers.delete(UPSTREAM_PATH_HEADER)
291
- if (!isAdmin && route !== '/wake' && route !== '/notify') {
292
- const claims = await config.authenticate(request, env)
293
- if (!claims) return json({ error: 'missing authentication' }, 401)
294
- headers.set(CLAIMS_HEADER, encodeURIComponent(JSON.stringify(claims)))
295
- }
296
- headers.set(NAMESPACE_HEADER, await namespaceHash(namespace))
297
- if (config.upstream) {
298
- const namespacePath =
299
- typeof config.upstream.namespacePath === 'function'
300
- ? config.upstream.namespacePath(namespace)
301
- : config.upstream.namespacePath
302
- if (!namespacePath.startsWith('/')) {
303
- return json({ error: 'upstream namespacePath must be an absolute path' }, 500)
304
- }
305
- headers.set(UPSTREAM_PATH_HEADER, namespacePath.replace(/\/$/, ''))
306
- }
307
-
308
- const forwarded = new Request(request, { headers })
309
- const id = env.SYNC_DO.idFromName(namespace)
310
- return env.SYNC_DO.get(id).fetch(forwarded)
311
- },
312
- }
313
- }
314
-
315
- /** Create the namespace Durable Object class for one bundled consumer config. */
316
- export function createSyncDurableObject<Env extends SyncHostEnv>(
317
- config: SyncHostConfig<Env>
318
- ) {
319
- validateSyncHostConfig(config)
320
- const compileQuery = createQueryCompiler(config.schema)
321
- const defaultRetainChanges = String(config.retainChanges ?? 4_096)
322
- const caps: PullCaps = validatePullCaps({ ...DEFAULT_CAPS, ...config.caps })
323
- const idleTeardownMs = config.idleTeardownMs ?? 5_000
324
- // A CF fan-out wakes every client into an HTTP pull. Give concurrent writer
325
- // requests a real batching window so a storm burst creates one pull wave.
326
- const wakeCoalesceMs = config.wakeCoalesceMs ?? 25
327
- const upstreamIntervalMs = config.upstream?.intervalMs ?? 15_000
328
- const upstreamLimit = config.upstream?.changeLimit ?? 1_000
329
- const ingestBudgetRows = config.upstream?.ingestBudgetRows ?? 150_000
330
- const ingestBudgetWindowMs = config.upstream?.ingestBudgetWindowMs ?? 5 * 60_000
331
- const ingestBackoffMs = config.upstream?.ingestBackoffMs ?? 1_000
332
- const ingestMaxBackoffMs = config.upstream?.ingestMaxBackoffMs ?? 60_000
333
- const delegateMaxAttempts = config.delegatedPushRetry?.maxAttempts ?? 3
334
- const delegateInitialBackoffMs = config.delegatedPushRetry?.initialBackoffMs ?? 100
335
- const delegateMaxBackoffMs = config.delegatedPushRetry?.maxBackoffMs ?? 1_000
336
- const delegateTimeoutMs = config.delegatedPushRetry?.timeoutMs ?? 5_000
337
-
338
- return class SyncDurableObject extends DurableObject<Env> {
339
- readonly #engineDb: SqlStorageSyncDb
340
- readonly #directSql: SqlStorageDirect
341
- readonly #mutatorSql: SqlStorageMutatorTransaction
342
- #bootID = crypto.randomUUID()
343
- #lastRequestAt = 0
344
- #hibernations = 0
345
- #dropNextPushResponse = false
346
- #counters = freshCounters()
347
- #pulling = new Set<string>()
348
- #wakeOrigins = new Set<string>()
349
- #wakeRecipients = new Set<WebSocket>()
350
- #wakePromise: Promise<void> | null = null
351
- #ingestPromise: Promise<number> | null = null
352
- #queryPullLocks = new Map<string, Promise<void>>()
353
- #recordingIngestBillable = false
354
- #ingestBreaker = new IngestCircuitBreaker({
355
- budgetRows: ingestBudgetRows,
356
- windowMs: ingestBudgetWindowMs,
357
- initialBackoffMs: ingestBackoffMs,
358
- maxBackoffMs: ingestMaxBackoffMs,
359
- now: () => Date.now(),
360
- })
361
-
362
- constructor(ctx: DurableObjectState, env: Env) {
363
- super(ctx, env)
364
- const recordRowsWritten = (rows: number) => {
365
- if (this.#recordingIngestBillable) this.#ingestBreaker.recordBillable(rows)
366
- }
367
- this.#engineDb = new SqlStorageSyncDb(ctx.storage.sql, recordRowsWritten)
368
- this.#directSql = new SqlStorageDirect(ctx.storage.sql, recordRowsWritten)
369
- this.#mutatorSql = new SqlStorageMutatorTransaction(
370
- this.#directSql,
371
- (ast, format) => this.#wasm(() => compileQuery(ast, format)),
372
- config.transactionQueryBudget
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
- #ingestAfterCurrent(upstreamPath: string | null): Promise<number> {
1034
- const current = this.#ingestPromise
1035
- return current
1036
- ? current.then(() => this.#ingest(upstreamPath))
1037
- : this.#ingest(upstreamPath)
1038
- }
1039
-
1040
- async #fetchDelegatedPush(
1041
- endpoint: URL,
1042
- headers: Headers,
1043
- body: ArrayBuffer,
1044
- provisioning = false
1045
- ): Promise<Response> {
1046
- const binding = this.#serviceBinding(
1047
- config.mutateBinding ?? config.upstream?.binding
1048
- )
1049
- let lastError: unknown = null
1050
- for (let attempt = 1; attempt <= delegateMaxAttempts; attempt++) {
1051
- let response: Response | null = null
1052
- try {
1053
- response = await binding.fetch(endpoint.toString(), {
1054
- method: 'POST',
1055
- headers,
1056
- body,
1057
- signal: AbortSignal.timeout(
1058
- provisioning ? Math.max(delegateTimeoutMs, 25_000) : delegateTimeoutMs
1059
- ),
1060
- })
1061
- } catch (error) {
1062
- lastError = error
1063
- }
1064
- if (
1065
- !shouldRetryDelegatedPush(
1066
- response?.status ?? null,
1067
- attempt,
1068
- delegateMaxAttempts
1069
- )
1070
- ) {
1071
- if (response) return response
1072
- throw lastError
1073
- }
1074
- await response?.body?.cancel()
1075
- const delayMs = retryDelayMs(
1076
- attempt,
1077
- delegateInitialBackoffMs,
1078
- delegateMaxBackoffMs
1079
- )
1080
- console.warn(
1081
- JSON.stringify({
1082
- event: 'sync_delegated_push_retry',
1083
- attempt,
1084
- maxAttempts: delegateMaxAttempts,
1085
- status: response?.status ?? null,
1086
- delayMs,
1087
- error: response ? null : errorMessage(lastError),
1088
- })
1089
- )
1090
- await scheduler.wait(delayMs)
1091
- }
1092
- throw lastError ?? new Error('delegated push retry exhausted')
1093
- }
1094
-
1095
- #log(fields: Record<string, unknown>): void {
1096
- console.log(
1097
- JSON.stringify({
1098
- event: 'sync_request',
1099
- hostVersion: config.hostVersion,
1100
- engineVersion: engine_version(),
1101
- ...fields,
1102
- })
1103
- )
1104
- }
1105
-
1106
- #enqueueWake(originClientID: string): Promise<void> {
1107
- this.#wakeOrigins.add(originClientID)
1108
- for (const socket of this.ctx.getWebSockets()) {
1109
- const attachment = socketAttachment(socket)
1110
- if (
1111
- !attachment ||
1112
- attachment.clientID === originClientID ||
1113
- this.#pulling.has(attachment.clientID)
1114
- ) {
1115
- continue
1116
- }
1117
- this.#wakeRecipients.add(socket)
1118
- }
1119
- return this.#scheduleWake()
1120
- }
1121
-
1122
- #scheduleWake(): Promise<void> {
1123
- if (!this.#wakePromise) {
1124
- const queuedAt = performance.now()
1125
- this.#wakePromise = (async () => {
1126
- await scheduler.wait(wakeCoalesceMs)
1127
- const fanoutStarted = performance.now()
1128
- const origins = this.#wakeOrigins
1129
- this.#wakeOrigins = new Set()
1130
- const recipients = this.#wakeRecipients
1131
- this.#wakeRecipients = new Set()
1132
- this.#counters.wakeBatches++
1133
- let sent = 0
1134
- const sockets = this.ctx.getWebSockets()
1135
- for (const socket of recipients) {
1136
- try {
1137
- socket.send('wake')
1138
- sent++
1139
- this.#counters.wakeFrames++
1140
- } catch {
1141
- // A closing hibernating socket disappears from getWebSockets;
1142
- // a race here is advisory and carries no correctness weight.
1143
- }
1144
- }
1145
- console.log(
1146
- JSON.stringify({
1147
- event: 'sync_wake',
1148
- hostVersion: config.hostVersion,
1149
- socketCount: sockets.length,
1150
- originCount: origins.size,
1151
- sent,
1152
- eligibleRecipients: recipients.size,
1153
- coalesceMs: fanoutStarted - queuedAt,
1154
- fanoutMs: performance.now() - fanoutStarted,
1155
- })
1156
- )
1157
- })().finally(() => {
1158
- this.#wakePromise = null
1159
- if (this.#wakeOrigins.size > 0) void this.#scheduleWake()
1160
- })
1161
- }
1162
- return this.#wakePromise
1163
- }
1164
-
1165
- async #pull(
1166
- request: Request,
1167
- claims: NormalizedClaims,
1168
- namespace: string
1169
- ): Promise<Response> {
1170
- this.#counters.pulls++
1171
- this.#engineDb.resetStats()
1172
- const started = performance.now()
1173
- let transactionMs = 0
1174
- let body: Record<string, unknown> | undefined
1175
- try {
1176
- body = await requestObject(request)
1177
- const queryAware =
1178
- this.#queryAwareOverride() ??
1179
- (typeof config.queryAware === 'function'
1180
- ? config.queryAware(claims)
1181
- : (config.queryAware ?? Boolean(config.resolveQuery)))
1182
- const transformVersion = queryAware
1183
- ? typeof config.queryTransformVersion === 'function'
1184
- ? config.queryTransformVersion(claims)
1185
- : (config.queryTransformVersion ?? 0)
1186
- : 0
1187
- if (!Number.isSafeInteger(transformVersion) || transformVersion < 0) {
1188
- throw new TypeError('queryTransformVersion must be a non-negative safe integer')
1189
- }
1190
- const releaseQueryPull =
1191
- queryAware && config.resolveQuery
1192
- ? await this.#acquireQueryPullLock(
1193
- typeof body.clientGroupID === 'string' ? body.clientGroupID : ''
1194
- )
1195
- : null
1196
- let response: Record<string, unknown>
1197
- try {
1198
- if (queryAware && body.queries) {
1199
- const queries = body.queries as {
1200
- version?: unknown
1201
- patch?: unknown
1202
- }
1203
- if (Array.isArray(queries.patch)) {
1204
- const patch = []
1205
- for (const operation of queries.patch) {
1206
- if (!operation || typeof operation !== 'object') {
1207
- patch.push(operation)
1208
- continue
1209
- }
1210
- const op = operation as Record<string, unknown>
1211
- if (op.op === 'put') {
1212
- if (!config.resolveQuery || typeof op.name !== 'string') {
1213
- throw requestError('query put requires a server-resolved named query')
1214
- }
1215
- if (!Array.isArray(op.args)) {
1216
- throw requestError('named query args must be an array')
1217
- }
1218
- const args = op.args as JsonValue[]
1219
- let ast: JsonValue
1220
- try {
1221
- // resolveQuery may be async and needs `env` (a consumer can
1222
- // delegate the transform to its app's real synced-queries
1223
- // endpoint over an app service binding — authenticate runs in the
1224
- // worker isolate, but the query loop runs here in the DO, so the
1225
- // binding must come from the DO's own env, not a shared global).
1226
- ast = await config.resolveQuery(op.name, args, claims, this.env)
1227
- } catch (error) {
1228
- throw requestError(`unknown or unsupported named query: ${op.name}`)
1229
- }
1230
- patch.push({
1231
- op: 'put',
1232
- hash: op.hash,
1233
- ast,
1234
- transformVersion,
1235
- })
1236
- } else patch.push(operation)
1237
- }
1238
- body = { ...body, queries: { ...queries, patch } }
1239
- }
1240
- }
1241
- if (queryAware) {
1242
- body = { ...body, _serverQueryTransformVersion: transformVersion }
1243
- }
1244
- const clientID = typeof body.clientID === 'string' ? body.clientID : ''
1245
- this.#pulling.add(clientID)
1246
- try {
1247
- const txStarted = performance.now()
1248
- const duringFault = this.#takeFault('pull_during_tx')
1249
- response = this.ctx.storage.transactionSync(() => {
1250
- const result = this.#wasm(() =>
1251
- queryAware
1252
- ? engine_handle_query_pull(
1253
- this.#engineDb,
1254
- config.schema,
1255
- this.#retainChanges(),
1256
- body,
1257
- claims.userID
1258
- )
1259
- : engine_handle_pull(
1260
- this.#engineDb,
1261
- config.schema,
1262
- this.#visibility(claims),
1263
- caps,
1264
- this.#retainChanges(),
1265
- body,
1266
- claims.userID
1267
- )
1268
- ) as Record<string, unknown>
1269
- if (duringFault) throw this.#faultError(duringFault, 'pull_during_tx')
1270
- return result
1271
- })
1272
- transactionMs = performance.now() - txStarted
1273
- } finally {
1274
- this.#pulling.delete(clientID)
1275
- }
1276
- } finally {
1277
- releaseQueryPull?.()
1278
- }
1279
- const afterPullFault = this.#takeFault('pull_after_commit')
1280
- if (afterPullFault) throw this.#faultError(afterPullFault, 'pull_after_commit')
1281
- const patch = Array.isArray(response.rowsPatch) ? response.rowsPatch : []
1282
- const queriesBody = body.queries as { patch?: unknown[] } | undefined
1283
- const queryPuts =
1284
- queryAware && Array.isArray(queriesBody?.patch)
1285
- ? queriesBody.patch.filter(
1286
- (entry) => (entry as { op?: unknown } | null)?.op === 'put'
1287
- ).length
1288
- : 0
1289
- this.#counters.queryRecompilations += queryPuts
1290
- const state = this.#engineStateBestEffort()
1291
- this.#log({
1292
- namespaceHash: namespace,
1293
- requestKind: 'pull',
1294
- resultClass: response.unchanged === true ? 'unchanged' : 'success',
1295
- inputCookie: body.cookie ?? null,
1296
- outputCookie: response.cookie ?? null,
1297
- retainedFloor: state?.floor ?? null,
1298
- currentWatermark: state?.watermark ?? null,
1299
- changeRowsScanned: null,
1300
- changeRowsIncluded: null,
1301
- queriesRecomputed: queryPuts,
1302
- rowPuts: patch.filter((entry) => entry?.op === 'put').length,
1303
- rowDeletes: patch.filter((entry) => entry?.op === 'del').length,
1304
- lmidAdvances: 0,
1305
- transactionMs,
1306
- totalMs: performance.now() - started,
1307
- resetReason: null,
1308
- wasmBoundaryCalls: this.#counters.wasmBoundaryCalls,
1309
- sql: this.#engineDb.stats,
1310
- })
1311
- return json(response)
1312
- } catch (error) {
1313
- const status = statusOf(error)
1314
- if (status === 409) this.#counters.resets++
1315
- if (status === 500) this.#counters.invariantFailures++
1316
- const state = this.#engineStateBestEffort()
1317
- this.#log({
1318
- namespaceHash: namespace,
1319
- requestKind: 'pull',
1320
- resultClass: status === 409 ? 'reset' : 'error',
1321
- inputCookie: body?.cookie ?? null,
1322
- outputCookie: null,
1323
- retainedFloor: state?.floor ?? null,
1324
- currentWatermark: state?.watermark ?? null,
1325
- changeRowsScanned: null,
1326
- changeRowsIncluded: 0,
1327
- queriesRecomputed: 0,
1328
- rowPuts: 0,
1329
- rowDeletes: 0,
1330
- lmidAdvances: 0,
1331
- transactionMs,
1332
- totalMs: performance.now() - started,
1333
- resetReason: status === 409 ? errorMessage(error) : null,
1334
- })
1335
- return json({ error: errorMessage(error) }, status)
1336
- }
1337
- }
1338
-
1339
- async #push(
1340
- request: Request,
1341
- claims: NormalizedClaims,
1342
- namespace: string,
1343
- upstreamPath: string | null
1344
- ): Promise<Response> {
1345
- this.#counters.pushes++
1346
- this.#engineDb.resetStats()
1347
- const started = performance.now()
1348
- let transactionMs = 0
1349
- let lmidAdvances = 0
1350
- let resultClass = 'success'
1351
- if (!this.#writerEnabled()) {
1352
- // Workerd requires the request stream to be consumed before the DO
1353
- // returns a response. Discard it without parsing or logging payloads.
1354
- await request.arrayBuffer()
1355
- const state = this.#engineStateBestEffort()
1356
- this.#log({
1357
- namespaceHash: namespace,
1358
- requestKind: 'push',
1359
- resultClass: 'writer_disabled',
1360
- inputCookie: null,
1361
- outputCookie: null,
1362
- retainedFloor: state?.floor ?? null,
1363
- currentWatermark: state?.watermark ?? null,
1364
- changeRowsScanned: 0,
1365
- changeRowsIncluded: 0,
1366
- queriesRecomputed: 0,
1367
- rowPuts: 0,
1368
- rowDeletes: 0,
1369
- lmidAdvances: 0,
1370
- transactionMs: 0,
1371
- totalMs: performance.now() - started,
1372
- resetReason: 'writer disabled by operator',
1373
- wasmBoundaryCalls: this.#counters.wasmBoundaryCalls,
1374
- sql: this.#engineDb.stats,
1375
- })
1376
- return json({ error: 'writer disabled by operator' }, 503)
1377
- }
1378
- if (config.mutateUrl) {
1379
- try {
1380
- const bytes = await request.arrayBuffer()
1381
- const body = JSON.parse(new TextDecoder().decode(bytes)) as Record<
1382
- string,
1383
- unknown
1384
- >
1385
- const plan = this.#wasm(() => engine_push_validate(body)) as PushPlan
1386
- if (plan.kind === 'respond') return json(plan.response)
1387
-
1388
- const endpoint = new URL(
1389
- `${upstreamPath ?? ''}${config.mutateUrl}`,
1390
- config.mutateOrigin ?? 'https://upstream.invalid'
1391
- )
1392
- const headers = new Headers(request.headers)
1393
- headers.delete(CLAIMS_HEADER)
1394
- headers.delete(NAMESPACE_HEADER)
1395
- headers.delete(UPSTREAM_PATH_HEADER)
1396
- headers.set('host', endpoint.host)
1397
- const upstreamResponse = await this.#fetchDelegatedPush(
1398
- endpoint,
1399
- headers,
1400
- bytes,
1401
- this.#engineState().upstreamWatermark === '0'
1402
- )
1403
- if (!upstreamResponse.ok) {
1404
- return new Response(upstreamResponse.body, upstreamResponse)
1405
- }
1406
- const upstreamBody = (await upstreamResponse.json()) as DelegatedPushBody
1407
- const delegatedResponse = upstreamBody.pushResponse ?? upstreamBody
1408
- if (isStructuredPushFailed(delegatedResponse)) {
1409
- // PushFailed is a successful protocol response describing an
1410
- // application-level failure. There are intentionally no mutation
1411
- // acknowledgements to finalize in the host; preserve the body so
1412
- // the Zero client can apply its retry/error policy.
1413
- return json({ pushResponse: delegatedResponse })
1414
- }
1415
- const acknowledged =
1416
- typeof upstreamBody.pushResponse === 'object' &&
1417
- upstreamBody.pushResponse !== null &&
1418
- 'mutations' in upstreamBody.pushResponse
1419
- ? upstreamBody.pushResponse.mutations
1420
- : upstreamBody.mutations
1421
- if (!Array.isArray(acknowledged)) {
1422
- throw new Error('delegated push returned no mutation results')
1423
- }
1424
- for (const mutation of plan.mutations) {
1425
- const ack = acknowledged.some(
1426
- (result) =>
1427
- result.id?.clientID === mutation.clientID &&
1428
- String(result.id?.id) === mutation.id
1429
- )
1430
- if (!ack) {
1431
- throw new Error(
1432
- `delegated push did not acknowledge ${mutation.clientID}:${mutation.id}`
1433
- )
1434
- }
1435
- }
1436
- // the delegated app response is causally visible through DATA by
1437
- // contract. start an ingest round after that response, even if an
1438
- // older round is still in flight, then journal lmids. every capped
1439
- // log prefix therefore preserves effects-before-ack.
1440
- await this.#ingestAfterCurrent(upstreamPath)
1441
- for (const mutation of plan.mutations) {
1442
- this.ctx.storage.transactionSync(() => {
1443
- const decision = this.#wasm(() =>
1444
- engine_preflight(
1445
- this.#engineDb,
1446
- plan.clientGroupID,
1447
- mutation.clientID,
1448
- mutation.id,
1449
- claims.userID
1450
- )
1451
- ) as Preflight
1452
- if (decision.kind === 'applied') {
1453
- this.#wasm(() =>
1454
- engine_finalize(
1455
- this.#engineDb,
1456
- plan.clientGroupID,
1457
- mutation.clientID,
1458
- mutation.id
1459
- )
1460
- )
1461
- lmidAdvances++
1462
- }
1463
- })
1464
- }
1465
- if (lmidAdvances > 0) {
1466
- this.ctx.storage.transactionSync(() =>
1467
- this.#wasm(() => engine_prune(this.#engineDb, this.#retainChanges()))
1468
- )
1469
- }
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 = createPostCommitEffects()
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.beginAttempt()
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: deferred.defer,
1515
- })
1516
- if (beforeCommitFault)
1517
- throw this.#faultError(
1518
- beforeCommitFault,
1519
- 'push_after_write_before_commit'
1520
- )
1521
- this.#wasm(() =>
1522
- engine_finalize(
1523
- this.#engineDb,
1524
- plan.clientGroupID,
1525
- mutation.clientID,
1526
- mutation.id
1527
- )
1528
- )
1529
- return decision
1530
- })
1531
- transactionMs += performance.now() - txStarted
1532
-
1533
- if ((preflight as Preflight).kind === 'replay') {
1534
- const expected = (preflight as Extract<Preflight, { kind: 'replay' }>)
1535
- .expected
1536
- results.push({
1537
- clientID: mutation.clientID,
1538
- id: mutation.id,
1539
- result: {
1540
- error: 'alreadyProcessed',
1541
- details: `Ignoring mutation from ${mutation.clientID} with ID ${mutation.id} as it was already processed. Expected: ${expected}`,
1542
- },
1543
- })
1544
- continue
1545
- }
1546
-
1547
- lmidAdvances++
1548
- results.push({
1549
- clientID: mutation.clientID,
1550
- id: mutation.id,
1551
- result: {},
1552
- })
1553
- // Keep the advisory fan-out off the push response's critical path.
1554
- // waitUntil anchors the coalescing timer across request completion,
1555
- // while the next serialized client push can join the same batch.
1556
- this.ctx.waitUntil(this.#enqueueWake(mutation.clientID))
1557
- await deferred.runAfterCommit((error) => {
1558
- this.#counters.externalEffectFailures++
1559
- console.error(
1560
- JSON.stringify({
1561
- event: 'sync_external_effect_error',
1562
- hostVersion: config.hostVersion,
1563
- error: errorMessage(error),
1564
- })
1565
- )
1566
- })
1567
- } catch (error) {
1568
- const isAppError = isMutationApplicationError(error)
1569
- if (!isAppError) throw error
1570
- this.#counters.applicationErrors++
1571
- resultClass = 'application_error'
1572
- const appError = error as Error & { details?: string }
1573
- const txStarted = performance.now()
1574
- await this.ctx.storage.transaction(async () => {
1575
- this.#wasm(() =>
1576
- engine_record_app_error(
1577
- this.#engineDb,
1578
- plan.clientGroupID,
1579
- mutation.clientID,
1580
- mutation.id,
1581
- claims.userID
1582
- )
1583
- )
1584
- })
1585
- transactionMs += performance.now() - txStarted
1586
- lmidAdvances++
1587
- results.push({
1588
- clientID: mutation.clientID,
1589
- id: mutation.id,
1590
- result: {
1591
- error: 'app',
1592
- message: appError.message,
1593
- details: appError.details ?? appError.message,
1594
- },
1595
- })
1596
- this.ctx.waitUntil(this.#enqueueWake(mutation.clientID))
1597
- }
1598
- }
1599
-
1600
- if (plan.mutations.length > 0) {
1601
- const txStarted = performance.now()
1602
- await this.ctx.storage.transaction(async () => {
1603
- this.#wasm(() => engine_prune(this.#engineDb, this.#retainChanges()))
1604
- })
1605
- transactionMs += performance.now() - txStarted
1606
- this.#counters.retentionRuns++
1607
- }
1608
-
1609
- const afterCommitFault = this.#takeFault('push_after_commit_before_response')
1610
- if (afterCommitFault)
1611
- throw this.#faultError(afterCommitFault, 'push_after_commit_before_response')
1612
-
1613
- const response = this.#wasm(() => engine_assemble_push_response(results))
1614
- const state = this.#engineStateBestEffort()
1615
- this.#log({
1616
- namespaceHash: namespace,
1617
- requestKind: 'push',
1618
- resultClass,
1619
- inputCookie: null,
1620
- outputCookie: null,
1621
- retainedFloor: state?.floor ?? null,
1622
- currentWatermark: state?.watermark ?? null,
1623
- changeRowsScanned: 0,
1624
- changeRowsIncluded: 0,
1625
- queriesRecomputed: 0,
1626
- rowPuts: 0,
1627
- rowDeletes: 0,
1628
- lmidAdvances,
1629
- transactionMs,
1630
- totalMs: performance.now() - started,
1631
- resetReason: null,
1632
- wasmBoundaryCalls: this.#counters.wasmBoundaryCalls,
1633
- sql: this.#engineDb.stats,
1634
- })
1635
- if (this.#dropNextPushResponse) {
1636
- this.#dropNextPushResponse = false
1637
- return json({ error: 'intentionally dropped push response' }, 503)
1638
- }
1639
- return json(response)
1640
- } catch (error) {
1641
- const status = statusOf(error)
1642
- if (status === 500) this.#counters.invariantFailures++
1643
- const state = this.#engineStateBestEffort()
1644
- this.#log({
1645
- namespaceHash: namespace,
1646
- requestKind: 'push',
1647
- resultClass: 'error',
1648
- inputCookie: null,
1649
- outputCookie: null,
1650
- retainedFloor: state?.floor ?? null,
1651
- currentWatermark: state?.watermark ?? null,
1652
- changeRowsScanned: 0,
1653
- changeRowsIncluded: 0,
1654
- queriesRecomputed: 0,
1655
- rowPuts: 0,
1656
- rowDeletes: 0,
1657
- lmidAdvances,
1658
- transactionMs,
1659
- totalMs: performance.now() - started,
1660
- resetReason: null,
1661
- })
1662
- return json({ error: errorMessage(error) }, status)
1663
- }
1664
- }
1665
-
1666
- #wake(request: Request): Response {
1667
- if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
1668
- return json({ error: 'websocket upgrade required' }, 426)
1669
- }
1670
- const clientID = new URL(request.url).searchParams.get('clientID')
1671
- if (!clientID) return json({ error: 'clientID is required' }, 400)
1672
- const pair = new WebSocketPair()
1673
- const [client, server] = Object.values(pair)
1674
- server.serializeAttachment({ clientID } satisfies SocketAttachment)
1675
- this.ctx.acceptWebSocket(server, [`client:${clientID}`])
1676
- // The alarm is only a safety net for an actively connected consumer.
1677
- // A namespace with no wake socket has nobody to notify; its next pull or
1678
- // push ingests synchronously. Arming from construction made every
1679
- // namespace poll upstream forever after its first request, even across
1680
- // DO eviction, producing a permanent rows-written floor at zero traffic.
1681
- this.#armUpstreamAlarm()
1682
- return new Response(null, { status: 101, webSocket: client })
1683
- }
1684
-
1685
- #admin(
1686
- route: string,
1687
- request: Request,
1688
- upstreamPath: string | null
1689
- ): Promise<Response> | Response {
1690
- if (route === '/admin/health') return json({ ok: true })
1691
- if (route === '/admin/upstream-write-budget' && request.method === 'GET')
1692
- return this.#upstreamWriteBudgetStatus()
1693
- if (route === '/admin/status') {
1694
- const heap = (
1695
- performance as Performance & {
1696
- memory?: {
1697
- usedJSHeapSize: number
1698
- totalJSHeapSize: number
1699
- jsHeapSizeLimit: number
1700
- }
1701
- }
1702
- ).memory
1703
- return this.ctx.storage.getAlarm().then((upstreamAlarmAt) =>
1704
- json({
1705
- bootID: this.#bootID,
1706
- idleTeardownMs,
1707
- hibernations: this.#hibernations,
1708
- databaseSizeBytes: this.ctx.storage.sql.databaseSize,
1709
- connectedWakeSockets: this.ctx.getWebSockets().length,
1710
- upstreamAlarmAt,
1711
- writerEnabled: this.#writerEnabled(),
1712
- wasmMemoryBytes: engine_memory_bytes(),
1713
- heapUsedBytes: heap?.usedJSHeapSize ?? null,
1714
- heapTotalBytes: heap?.totalJSHeapSize ?? null,
1715
- heapLimitBytes: heap?.jsHeapSizeLimit ?? null,
1716
- engine: this.#engineStateBestEffort(),
1717
- counters: this.#counters,
1718
- ingestBreaker: this.#ingestBreaker.status(),
1719
- })
1720
- )
1721
- }
1722
- if (route === '/admin/sql') {
1723
- return request.json().then((body) => {
1724
- const { params, query } = body as { params?: unknown; query?: string }
1725
- if (typeof query !== 'string') return json({ error: 'query is required' }, 400)
1726
- try {
1727
- return json({ rows: this.#directSql.query(query, decodeSqlParams(params)) })
1728
- } catch (error) {
1729
- if (error instanceof TypeError && error.message.startsWith('params')) {
1730
- return json({ error: `invalid params: ${error.message}` }, 400)
1731
- }
1732
- if (
1733
- error instanceof TypeError &&
1734
- error.message === 'transaction SQL is host-owned and forbidden'
1735
- ) {
1736
- return json({ error: error.message }, 400)
1737
- }
1738
- throw error
1739
- }
1740
- })
1741
- }
1742
- if (route === '/admin/invalidate') {
1743
- this.ctx.storage.transactionSync(() =>
1744
- this.#wasm(() => engine_invalidate(this.#engineDb))
1745
- )
1746
- return json({ ok: true, engine: this.#engineState() })
1747
- }
1748
- if (route === '/admin/resnapshot') {
1749
- if (request.method !== 'POST') {
1750
- return json({ error: 'method not allowed' }, 405)
1751
- }
1752
- return (async () => {
1753
- try {
1754
- const beforeUpstreamWatermark = this.#engineState().upstreamWatermark
1755
- const applied = await this.#ingest(upstreamPath, true)
1756
- const engine = this.#engineState()
1757
- return json({
1758
- ok: true,
1759
- applied,
1760
- beforeUpstreamWatermark,
1761
- afterUpstreamWatermark: engine.upstreamWatermark,
1762
- engine,
1763
- })
1764
- } catch (error) {
1765
- return json(errorBody(error), statusOf(error))
1766
- }
1767
- })()
1768
- }
1769
- if (route === '/admin/drop-next-push-response') {
1770
- this.#dropNextPushResponse = true
1771
- return json({ ok: true })
1772
- }
1773
- if (route === '/admin/restart') {
1774
- this.ctx.abort('admin requested durable object restart')
1775
- return json({ ok: true, bootID: this.#bootID })
1776
- }
1777
- if (route === '/admin/visibility') {
1778
- return request.json().then((body) => {
1779
- const enabled = Boolean((body as { enabled?: unknown }).enabled)
1780
- this.#controlSet('visibilityEnabled', enabled ? '1' : '0')
1781
- return json({ ok: true, enabled })
1782
- })
1783
- }
1784
- if (route === '/admin/query-aware') {
1785
- return request.json().then((body) => {
1786
- const enabled = Boolean((body as { enabled?: unknown }).enabled)
1787
- this.#controlSet('queryAwareOverride', enabled ? '1' : '0')
1788
- return json({ ok: true, enabled })
1789
- })
1790
- }
1791
- if (route === '/admin/retention') {
1792
- return request
1793
- .json()
1794
- .catch(() => ({}))
1795
- .then((body) => {
1796
- const value = Number((body as { retainChanges?: unknown }).retainChanges)
1797
- if (!Number.isSafeInteger(value) || value < 0)
1798
- return json({ error: 'invalid retainChanges' }, 400)
1799
- this.#controlSet('retainChanges', String(value))
1800
- return json({ ok: true, retainChanges: value })
1801
- })
1802
- }
1803
- if (route === '/admin/writer') {
1804
- if (request.method === 'GET')
1805
- return json({ writerEnabled: this.#writerEnabled() })
1806
- return request
1807
- .json()
1808
- .catch(() => ({}))
1809
- .then((body) => {
1810
- const enabled = (body as { enabled?: unknown }).enabled
1811
- if (typeof enabled !== 'boolean')
1812
- return json({ error: 'enabled must be a boolean' }, 400)
1813
- this.#controlSet('writerEnabled', enabled ? '1' : '0')
1814
- return json({ ok: true, writerEnabled: enabled })
1815
- })
1816
- }
1817
- if (route === '/admin/ingest-breaker') {
1818
- if (request.method === 'GET') return json(this.#ingestBreaker.status())
1819
- this.#ingestBreaker.reopen()
1820
- this.#controlDelete(
1821
- 'ingestBreakerReason',
1822
- 'ingestBreakerRetryAt',
1823
- 'ingestBreakerTrips'
1824
- )
1825
- console.log(JSON.stringify({ event: 'sync_upstream_ingest_breaker_reopened' }))
1826
- return json({ ok: true, ...this.#ingestBreaker.status() })
1827
- }
1828
- if (route === '/admin/fault') {
1829
- return request
1830
- .json()
1831
- .catch(() => ({}))
1832
- .then((body) => {
1833
- const value = body as {
1834
- clear?: unknown
1835
- point?: unknown
1836
- kind?: unknown
1837
- }
1838
- if (value.clear === true) {
1839
- this.#directSql.exec(
1840
- "DELETE FROM _zsync_host_control WHERE key IN ('faultPoint', 'faultKind')"
1841
- )
1842
- return json({ ok: true, armed: null })
1843
- }
1844
- const points: FaultPoint[] = [
1845
- 'push_before_mutation',
1846
- 'push_after_write_before_commit',
1847
- 'push_after_commit_before_response',
1848
- 'pull_during_tx',
1849
- 'pull_after_commit',
1850
- ]
1851
- if (!points.includes(value.point as FaultPoint))
1852
- return json({ error: 'invalid fault point' }, 400)
1853
- if (value.kind !== 'error' && value.kind !== 'quota')
1854
- return json({ error: 'invalid fault kind' }, 400)
1855
- this.#controlSet('faultPoint', value.point as string)
1856
- this.#controlSet('faultKind', value.kind)
1857
- return json({
1858
- ok: true,
1859
- armed: { point: value.point, kind: value.kind },
1860
- })
1861
- })
1862
- }
1863
- return json({ error: 'not found' }, 404)
1864
- }
1865
-
1866
- async fetch(request: Request): Promise<Response> {
1867
- this.#simulateIdleTeardown(Date.now())
1868
- const route = routeAfterNamespace(new URL(request.url).pathname)
1869
- const namespace = request.headers.get(NAMESPACE_HEADER) ?? 'unknown'
1870
- const upstreamPath = this.#rememberUpstreamPath(request)
1871
- if (route.startsWith('/admin/')) return this.#admin(route, request, upstreamPath)
1872
-
1873
- if (route === '/wake' && request.method === 'GET') return this.#wake(request)
1874
- if (route === '/notify' && request.method === 'POST') {
1875
- try {
1876
- const applied = await this.#ingest(upstreamPath)
1877
- return json({ ok: true, applied })
1878
- } catch (error) {
1879
- return json(errorBody(error), statusOf(error))
1880
- }
1881
- }
1882
-
1883
- const claims = claimsFromRequest(request)
1884
- if (!claims) return json({ error: 'missing normalized claims' }, 401)
1885
- if (route === '/pull' && request.method === 'POST') {
1886
- try {
1887
- await this.#ingest(upstreamPath)
1888
- } catch (error) {
1889
- // Workerd requires a forwarded request body to be consumed even when
1890
- // ingest fails before the pull handler parses it.
1891
- await request.arrayBuffer()
1892
- return json(errorBody(error), statusOf(error))
1893
- }
1894
- return this.#pull(request, claims, namespace)
1895
- }
1896
- if (route === '/push' && request.method === 'POST') {
1897
- // A push may be the first request for a fresh namespace. DATA's
1898
- // /changes call is also its schema-provisioning barrier, so complete it
1899
- // before delegating the mutation to APP. Pull already enforces this
1900
- // ordering above; skipping it here let APP race half-created tables and
1901
- // surface a terminal, bodyless 500 to Zero.
1902
- try {
1903
- await this.#ingest(upstreamPath)
1904
- } catch (error) {
1905
- await request.arrayBuffer()
1906
- return json(errorBody(error), statusOf(error))
1907
- }
1908
- return this.#push(request, claims, namespace, upstreamPath)
1909
- }
1910
- return json({ error: 'not found' }, 404)
1911
- }
1912
-
1913
- async alarm(): Promise<void> {
1914
- if (this.ctx.getWebSockets().length === 0) return
1915
- try {
1916
- await this.#ingest()
1917
- } catch (error) {
1918
- console.error(
1919
- JSON.stringify({
1920
- event: 'sync_upstream_ingest_error',
1921
- status: statusOf(error),
1922
- error: errorMessage(error),
1923
- })
1924
- )
1925
- } finally {
1926
- if (this.ctx.getWebSockets().length > 0) {
1927
- const retryAfterMs = this.#ingestBreaker.status().retryAfterMs
1928
- await this.ctx.storage.setAlarm(
1929
- Date.now() + Math.max(upstreamIntervalMs, retryAfterMs)
1930
- )
1931
- }
1932
- }
1933
- }
1934
-
1935
- webSocketMessage(socket: WebSocket, message: string | ArrayBuffer): void {
1936
- if (message === 'ping') socket.send('pong')
1937
- }
1938
-
1939
- webSocketClose(
1940
- socket: WebSocket,
1941
- code: number,
1942
- reason: string,
1943
- _wasClean: boolean
1944
- ): void {
1945
- // The peer already closed, so echo the close to release the socket — but
1946
- // WebSocket.close() rejects reserved/absent codes (1005 "no status", 1006
1947
- // abnormal, 1015) with InvalidAccessError, and a real browser routinely
1948
- // closes with 1001/1005. An uncaught throw here aborts the DO, so only
1949
- // echo an application-permitted code and otherwise close cleanly.
1950
- const echoable = code === 1000 || (code >= 3000 && code <= 4999)
1951
- socketCloseQuietly(socket, echoable ? code : 1000, echoable ? reason : '')
1952
- }
1953
-
1954
- webSocketError(socket: WebSocket, _error: unknown): void {
1955
- socketCloseQuietly(socket, 1011, 'wake socket error')
1956
- }
1957
- }
1958
- }