orez-sync-cf-host 0.5.17 → 0.5.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/host.ts CHANGED
@@ -1,12 +1,23 @@
1
1
  import { DurableObject } from 'cloudflare:workers'
2
2
 
3
3
  import { validatePullCaps, validateSyncHostConfig } from './config.js'
4
+ import { createPostCommitEffects } from './post-commit.js'
5
+ import { createQueryCompiler } from './query-compiler.js'
4
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,
5
16
  engine_apply_upstream,
6
- engine_apply_upstream_snapshot,
7
17
  engine_assemble_push_response,
8
- engine_compile_query,
18
+ engine_begin_snapshot_generation,
9
19
  engine_finalize,
20
+ engine_finalize_snapshot_generation,
10
21
  engine_handle_pull,
11
22
  engine_handle_query_pull,
12
23
  engine_init_query_schema,
@@ -16,18 +27,11 @@ import {
16
27
  engine_preflight,
17
28
  engine_prune,
18
29
  engine_push_validate,
30
+ engine_read_snapshot_progress,
19
31
  engine_record_app_error,
20
32
  engine_state,
21
33
  engine_version,
22
- initSync,
23
- } from './generated/sync_wasm.js'
24
- import wasmModule from './generated/sync_wasm_bg.wasm'
25
- import {
26
- SqlStorageDirect,
27
- SqlStorageMutatorTransaction,
28
- SqlStorageSyncDb,
29
- } from './sql-storage-adapter.js'
30
- import { MutationApplicationError } from './types.js'
34
+ } from './wasm.js'
31
35
  import {
32
36
  IngestBreakerError,
33
37
  IngestCircuitBreaker,
@@ -36,7 +40,6 @@ import {
36
40
  } from './write-safeguards.js'
37
41
 
38
42
  import type {
39
- DeferredEffect,
40
43
  JsonValue,
41
44
  NormalizedClaims,
42
45
  PullCaps,
@@ -44,11 +47,11 @@ import type {
44
47
  SyncHostEnv,
45
48
  } from './types.js'
46
49
 
47
- initSync({ module: wasmModule })
48
-
49
50
  const CLAIMS_HEADER = 'x-orez-sync-claims'
50
51
  const NAMESPACE_HEADER = 'x-orez-sync-namespace'
51
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
52
55
  const DEFAULT_CAPS: PullCaps = {
53
56
  maxChangeRows: 10_000,
54
57
  maxChangeBytes: 2_000_000,
@@ -96,6 +99,19 @@ type ApplyUpstreamResult = {
96
99
  applied: number
97
100
  caughtUp: boolean
98
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
+ }
99
115
  type SocketAttachment = { clientID: string }
100
116
  type FaultPoint =
101
117
  | 'push_before_mutation'
@@ -253,7 +269,15 @@ export function createSyncWorker<Env extends SyncHostEnv>(
253
269
 
254
270
  const route = routeAfterNamespace(new URL(request.url).pathname)
255
271
  const isAdmin = route.startsWith('/admin/')
256
- if (isAdmin) {
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) {
257
281
  const authorized = config.authorizeAdmin
258
282
  ? await config.authorizeAdmin(request, env)
259
283
  : Boolean(env.ADMIN_KEY) && request.headers.get('x-admin-key') === env.ADMIN_KEY
@@ -264,10 +288,6 @@ export function createSyncWorker<Env extends SyncHostEnv>(
264
288
  headers.delete(CLAIMS_HEADER)
265
289
  headers.delete(NAMESPACE_HEADER)
266
290
  headers.delete(UPSTREAM_PATH_HEADER)
267
- // Wake sockets carry only a client ID and the literal "wake" hint; they
268
- // expose no rows, claims, or mutation state. Keep this advisory channel
269
- // compatible with the vendored transport, which cannot attach an HTTP
270
- // Authorization header to its native WebSocket constructor.
271
291
  if (!isAdmin && route !== '/wake' && route !== '/notify') {
272
292
  const claims = await config.authenticate(request, env)
273
293
  if (!claims) return json({ error: 'missing authentication' }, 401)
@@ -297,6 +317,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
297
317
  config: SyncHostConfig<Env>
298
318
  ) {
299
319
  validateSyncHostConfig(config)
320
+ const compileQuery = createQueryCompiler(config.schema)
300
321
  const defaultRetainChanges = String(config.retainChanges ?? 4_096)
301
322
  const caps: PullCaps = validatePullCaps({ ...DEFAULT_CAPS, ...config.caps })
302
323
  const idleTeardownMs = config.idleTeardownMs ?? 5_000
@@ -328,6 +349,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
328
349
  #wakeRecipients = new Set<WebSocket>()
329
350
  #wakePromise: Promise<void> | null = null
330
351
  #ingestPromise: Promise<number> | null = null
352
+ #queryPullLocks = new Map<string, Promise<void>>()
331
353
  #recordingIngestBillable = false
332
354
  #ingestBreaker = new IngestCircuitBreaker({
333
355
  budgetRows: ingestBudgetRows,
@@ -344,8 +366,10 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
344
366
  }
345
367
  this.#engineDb = new SqlStorageSyncDb(ctx.storage.sql, recordRowsWritten)
346
368
  this.#directSql = new SqlStorageDirect(ctx.storage.sql, recordRowsWritten)
347
- this.#mutatorSql = new SqlStorageMutatorTransaction(this.#directSql, (ast) =>
348
- this.#wasm(() => engine_compile_query(config.schema, ast))
369
+ this.#mutatorSql = new SqlStorageMutatorTransaction(
370
+ this.#directSql,
371
+ (ast, format) => this.#wasm(() => compileQuery(ast, format)),
372
+ config.transactionQueryBudget
349
373
  )
350
374
  ctx.blockConcurrencyWhile(async () => {
351
375
  ctx.storage.transactionSync(() => {
@@ -391,6 +415,22 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
391
415
  return call()
392
416
  }
393
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
+
394
434
  #simulateIdleTeardown(now: number): void {
395
435
  if (this.#lastRequestAt > 0 && now - this.#lastRequestAt >= idleTeardownMs) {
396
436
  this.#bootID = crypto.randomUUID()
@@ -505,9 +545,13 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
505
545
  )
506
546
  }
507
547
 
508
- #engineState(): EngineState | null {
548
+ #engineState(): EngineState {
549
+ return this.#wasm(() => engine_state(this.#engineDb)) as EngineState
550
+ }
551
+
552
+ #engineStateBestEffort(): EngineState | null {
509
553
  try {
510
- return this.#wasm(() => engine_state(this.#engineDb)) as EngineState
554
+ return this.#engineState()
511
555
  } catch {
512
556
  return null
513
557
  }
@@ -533,7 +577,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
533
577
  async #upstreamWriteBudgetStatus(): Promise<Response> {
534
578
  if (!config.upstream) return json({ error: 'upstream is not configured' }, 404)
535
579
  const path = this.#controlGet('upstreamPath')
536
- if (!path) return json({ error: 'upstream path is not known yet' }, 409)
580
+ if (path === null) return json({ error: 'upstream path is not known yet' }, 409)
537
581
  const endpoint = new URL(`${path}/_orez/write-budget`, 'https://upstream.invalid')
538
582
  const response = await this.#serviceBinding().fetch(endpoint.toString(), {
539
583
  headers: { host: endpoint.host },
@@ -553,7 +597,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
553
597
  #rememberUpstreamPath(request: Request): string | null {
554
598
  if (!config.upstream) return null
555
599
  const path = request.headers.get(UPSTREAM_PATH_HEADER)
556
- if (path) {
600
+ if (path !== null) {
557
601
  this.#controlSet('upstreamPath', path)
558
602
  return path
559
603
  }
@@ -591,9 +635,14 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
591
635
  return apply()
592
636
  } catch (error) {
593
637
  this.#recordingIngestBillable = false
594
- if (error instanceof IngestBreakerError) {
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
+ ) {
595
645
  this.#persistIngestBreaker()
596
- const status = this.#ingestBreaker.status()
597
646
  console.error(
598
647
  JSON.stringify({
599
648
  event: 'sync_upstream_ingest_breaker_tripped',
@@ -609,37 +658,124 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
609
658
  }
610
659
  }
611
660
 
612
- async #applyUpstreamSnapshot(
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(
613
709
  path: string,
614
- cursor: string,
615
- allowSameCursor: boolean
616
- ): Promise<ApplyUpstreamResult> {
710
+ table: string,
711
+ cursor: string | null,
712
+ limit: number
713
+ ): Promise<SnapshotPage> {
617
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)
618
718
  const response = await this.#serviceBinding().fetch(endpoint.toString(), {
619
719
  headers: { host: endpoint.host },
620
720
  })
621
721
  if (!response.ok) {
622
- throw new Error(`upstream snapshot returned ${response.status}`)
623
- }
624
- const snapshot = await response.json()
625
- const rebuilt = this.#withIngestBilling({ phase: 'snapshot', cursor }, () =>
626
- this.ctx.storage.transactionSync(() =>
627
- this.#wasm(() =>
628
- engine_apply_upstream_snapshot(this.#engineDb, config.schema, snapshot)
629
- )
722
+ throw requestError(
723
+ `upstream snapshot page returned ${response.status}`,
724
+ response.status >= 500 ? 502 : response.status
630
725
  )
631
- ) as ApplyUpstreamResult
632
- this.#recordIngestLogicalRows(rebuilt.applied)
633
- const nextCursor = this.#engineState()?.upstreamWatermark ?? cursor
634
- if (!allowSameCursor && String(nextCursor) === String(cursor)) {
635
- this.#tripIngest('ingestCursorStalled', {
636
- phase: 'snapshot',
637
- cursor,
638
- resultWatermark: rebuilt.watermark,
639
- applied: rebuilt.applied,
640
- })
641
726
  }
642
- return rebuilt
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 }
643
779
  }
644
780
 
645
781
  #ingest(upstreamPath?: string | null, forceSnapshot = false): Promise<number> {
@@ -653,21 +789,172 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
653
789
  ? this.#ingestPromise.then(() => this.#ingest(upstreamPath, true))
654
790
  : this.#ingestPromise
655
791
  }
656
- this.#ingestBreaker.assertReady()
657
792
  const path = upstreamPath ?? this.#controlGet('upstreamPath')
658
- if (!path) {
793
+ if (path === null) {
659
794
  return forceSnapshot
660
795
  ? Promise.reject(requestError('upstream path is not available'))
661
796
  : Promise.resolve(0)
662
797
  }
663
798
  this.#ingestPromise = (async () => {
799
+ let progress = this.#snapshotProgress()
800
+ this.#ingestBreaker.assertReady()
801
+ const startingWatermark = this.#engineState().watermark
664
802
  let total = 0
803
+ let pendingPage: SnapshotPage | null = null
804
+ let snapshotPageLimit = DEFAULT_SNAPSHOT_PAGE_ROWS
805
+ let snapshotCompleted = false
665
806
  for (;;) {
666
- const cursor = this.#engineState()?.upstreamWatermark ?? '0'
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
667
952
  if (forceSnapshot) {
668
953
  forceSnapshot = false
669
- const rebuilt = await this.#applyUpstreamSnapshot(path, cursor, true)
670
- total += rebuilt.applied
954
+ const begun = await this.#beginSnapshotGeneration(path)
955
+ progress = begun.progress
956
+ pendingPage = begun.page
957
+ snapshotPageLimit = begun.pageLimit
671
958
  continue
672
959
  }
673
960
  const endpoint = new URL(`${path}/changes`, 'https://upstream.invalid')
@@ -677,8 +964,10 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
677
964
  headers: { host: endpoint.host },
678
965
  })
679
966
  if (response.status === 410) {
680
- const rebuilt = await this.#applyUpstreamSnapshot(path, cursor, false)
681
- total += rebuilt.applied
967
+ const begun = await this.#beginSnapshotGeneration(path)
968
+ progress = begun.progress
969
+ pendingPage = begun.page
970
+ snapshotPageLimit = begun.pageLimit
682
971
  continue
683
972
  }
684
973
  if (!response.ok) {
@@ -704,7 +993,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
704
993
  ) as ApplyUpstreamResult
705
994
  total += result.applied
706
995
  this.#recordIngestLogicalRows(result.applied)
707
- const nextCursor = this.#engineState()?.upstreamWatermark ?? cursor
996
+ const nextCursor = this.#engineState().upstreamWatermark
708
997
  if (batch.changes.length > 0 && String(nextCursor) === String(cursor)) {
709
998
  this.#tripIngest('ingestCursorStalled', {
710
999
  phase: 'changes',
@@ -730,7 +1019,10 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
730
1019
  }
731
1020
  }
732
1021
  this.#recoverIngestBreaker()
733
- if (total > 0) await this.#enqueueWake('__upstream__')
1022
+ const endingWatermark = this.#engineState().watermark
1023
+ if (snapshotCompleted || total > 0 || endingWatermark !== startingWatermark) {
1024
+ await this.#enqueueWake('__upstream__')
1025
+ }
734
1026
  return total
735
1027
  })().finally(() => {
736
1028
  this.#ingestPromise = null
@@ -738,6 +1030,13 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
738
1030
  return this.#ingestPromise
739
1031
  }
740
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
+
741
1040
  async #fetchDelegatedPush(
742
1041
  endpoint: URL,
743
1042
  headers: Headers,
@@ -804,23 +1103,6 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
804
1103
  )
805
1104
  }
806
1105
 
807
- async #runEffects(effects: DeferredEffect[]): Promise<void> {
808
- for (const effect of effects) {
809
- try {
810
- await effect()
811
- } catch (error) {
812
- this.#counters.externalEffectFailures++
813
- console.error(
814
- JSON.stringify({
815
- event: 'sync_external_effect_error',
816
- hostVersion: config.hostVersion,
817
- error: errorMessage(error),
818
- })
819
- )
820
- }
821
- }
822
- }
823
-
824
1106
  #enqueueWake(originClientID: string): Promise<void> {
825
1107
  this.#wakeOrigins.add(originClientID)
826
1108
  for (const socket of this.ctx.getWebSockets()) {
@@ -905,84 +1187,94 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
905
1187
  if (!Number.isSafeInteger(transformVersion) || transformVersion < 0) {
906
1188
  throw new TypeError('queryTransformVersion must be a non-negative safe integer')
907
1189
  }
908
- if (queryAware && body.queries) {
909
- const queries = body.queries as {
910
- version?: unknown
911
- patch?: unknown
912
- }
913
- if (Array.isArray(queries.patch)) {
914
- const patch = []
915
- for (const operation of queries.patch) {
916
- if (!operation || typeof operation !== 'object') {
917
- patch.push(operation)
918
- continue
919
- }
920
- const op = operation as Record<string, unknown>
921
- if (op.op === 'put') {
922
- if (!config.resolveQuery || typeof op.name !== 'string') {
923
- throw requestError('query put requires a server-resolved named query')
924
- }
925
- if (!Array.isArray(op.args)) {
926
- throw requestError('named query args must be an array')
927
- }
928
- const args = op.args as JsonValue[]
929
- let ast: JsonValue
930
- try {
931
- // resolveQuery may be async and needs `env` (a consumer can
932
- // delegate the transform to its app's real synced-queries
933
- // endpoint over an app service binding — authenticate runs in the
934
- // worker isolate, but the query loop runs here in the DO, so the
935
- // binding must come from the DO's own env, not a shared global).
936
- ast = await config.resolveQuery(op.name, args, claims, this.env)
937
- } catch (error) {
938
- throw requestError(`unknown or unsupported named query: ${op.name}`)
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
939
1209
  }
940
- patch.push({
941
- op: 'put',
942
- hash: op.hash,
943
- ast,
944
- transformVersion,
945
- })
946
- } else patch.push(operation)
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 } }
947
1239
  }
948
- body = { ...body, queries: { ...queries, patch } }
949
1240
  }
950
- }
951
- if (queryAware) {
952
- body = { ...body, _serverQueryTransformVersion: transformVersion }
953
- }
954
- const clientID = typeof body.clientID === 'string' ? body.clientID : ''
955
- this.#pulling.add(clientID)
956
- let response: Record<string, unknown>
957
- try {
958
- const txStarted = performance.now()
959
- const duringFault = this.#takeFault('pull_during_tx')
960
- response = this.ctx.storage.transactionSync(() => {
961
- const result = this.#wasm(() =>
962
- queryAware
963
- ? engine_handle_query_pull(
964
- this.#engineDb,
965
- config.schema,
966
- this.#retainChanges(),
967
- body,
968
- claims.userID
969
- )
970
- : engine_handle_pull(
971
- this.#engineDb,
972
- config.schema,
973
- this.#visibility(claims),
974
- caps,
975
- this.#retainChanges(),
976
- body,
977
- claims.userID
978
- )
979
- ) as Record<string, unknown>
980
- if (duringFault) throw this.#faultError(duringFault, 'pull_during_tx')
981
- return result
982
- })
983
- transactionMs = performance.now() - txStarted
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
+ }
984
1276
  } finally {
985
- this.#pulling.delete(clientID)
1277
+ releaseQueryPull?.()
986
1278
  }
987
1279
  const afterPullFault = this.#takeFault('pull_after_commit')
988
1280
  if (afterPullFault) throw this.#faultError(afterPullFault, 'pull_after_commit')
@@ -995,7 +1287,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
995
1287
  ).length
996
1288
  : 0
997
1289
  this.#counters.queryRecompilations += queryPuts
998
- const state = this.#engineState()
1290
+ const state = this.#engineStateBestEffort()
999
1291
  this.#log({
1000
1292
  namespaceHash: namespace,
1001
1293
  requestKind: 'pull',
@@ -1021,14 +1313,15 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1021
1313
  const status = statusOf(error)
1022
1314
  if (status === 409) this.#counters.resets++
1023
1315
  if (status === 500) this.#counters.invariantFailures++
1316
+ const state = this.#engineStateBestEffort()
1024
1317
  this.#log({
1025
1318
  namespaceHash: namespace,
1026
1319
  requestKind: 'pull',
1027
1320
  resultClass: status === 409 ? 'reset' : 'error',
1028
1321
  inputCookie: body?.cookie ?? null,
1029
1322
  outputCookie: null,
1030
- retainedFloor: this.#engineState()?.floor ?? null,
1031
- currentWatermark: this.#engineState()?.watermark ?? null,
1323
+ retainedFloor: state?.floor ?? null,
1324
+ currentWatermark: state?.watermark ?? null,
1032
1325
  changeRowsScanned: null,
1033
1326
  changeRowsIncluded: 0,
1034
1327
  queriesRecomputed: 0,
@@ -1059,7 +1352,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1059
1352
  // Workerd requires the request stream to be consumed before the DO
1060
1353
  // returns a response. Discard it without parsing or logging payloads.
1061
1354
  await request.arrayBuffer()
1062
- const state = this.#engineState()
1355
+ const state = this.#engineStateBestEffort()
1063
1356
  this.#log({
1064
1357
  namespaceHash: namespace,
1065
1358
  requestKind: 'push',
@@ -1094,7 +1387,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1094
1387
 
1095
1388
  const endpoint = new URL(
1096
1389
  `${upstreamPath ?? ''}${config.mutateUrl}`,
1097
- 'https://upstream.invalid'
1390
+ config.mutateOrigin ?? 'https://upstream.invalid'
1098
1391
  )
1099
1392
  const headers = new Headers(request.headers)
1100
1393
  headers.delete(CLAIMS_HEADER)
@@ -1105,7 +1398,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1105
1398
  endpoint,
1106
1399
  headers,
1107
1400
  bytes,
1108
- this.#engineState()?.upstreamWatermark === '0'
1401
+ this.#engineState().upstreamWatermark === '0'
1109
1402
  )
1110
1403
  if (!upstreamResponse.ok) {
1111
1404
  return new Response(upstreamResponse.body, upstreamResponse)
@@ -1134,7 +1427,18 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1134
1427
  result.id?.clientID === mutation.clientID &&
1135
1428
  String(result.id?.id) === mutation.id
1136
1429
  )
1137
- if (!ack) continue
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) {
1138
1442
  this.ctx.storage.transactionSync(() => {
1139
1443
  const decision = this.#wasm(() =>
1140
1444
  engine_preflight(
@@ -1163,7 +1467,6 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1163
1467
  this.#wasm(() => engine_prune(this.#engineDb, this.#retainChanges()))
1164
1468
  )
1165
1469
  }
1166
- await this.#ingest(upstreamPath)
1167
1470
  return json({ pushResponse: delegatedResponse })
1168
1471
  } catch (error) {
1169
1472
  const status = statusOf(error)
@@ -1180,7 +1483,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1180
1483
 
1181
1484
  const results: MutationResult[] = []
1182
1485
  for (const mutation of plan.mutations) {
1183
- const deferred: DeferredEffect[] = []
1486
+ const deferred = createPostCommitEffects()
1184
1487
  try {
1185
1488
  const txStarted = performance.now()
1186
1489
  // consume the before-commit fault OUTSIDE the transaction it is
@@ -1191,7 +1494,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1191
1494
  const preflight = await this.ctx.storage.transaction(async () => {
1192
1495
  // Storage transactions may retry their closure. Never carry a
1193
1496
  // deferred effect from an abandoned attempt into the commit.
1194
- deferred.length = 0
1497
+ deferred.beginAttempt()
1195
1498
  const decision = this.#wasm(() =>
1196
1499
  engine_preflight(
1197
1500
  this.#engineDb,
@@ -1208,9 +1511,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1208
1511
  claims,
1209
1512
  clientID: mutation.clientID,
1210
1513
  mutationID: mutation.id,
1211
- defer(effect) {
1212
- deferred.push(effect)
1213
- },
1514
+ defer: deferred.defer,
1214
1515
  })
1215
1516
  if (beforeCommitFault)
1216
1517
  throw this.#faultError(
@@ -1253,9 +1554,18 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1253
1554
  // waitUntil anchors the coalescing timer across request completion,
1254
1555
  // while the next serialized client push can join the same batch.
1255
1556
  this.ctx.waitUntil(this.#enqueueWake(mutation.clientID))
1256
- await this.#runEffects(deferred)
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
+ })
1257
1567
  } catch (error) {
1258
- const isAppError = error instanceof MutationApplicationError
1568
+ const isAppError = isMutationApplicationError(error)
1259
1569
  if (!isAppError) throw error
1260
1570
  this.#counters.applicationErrors++
1261
1571
  resultClass = 'application_error'
@@ -1301,7 +1611,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1301
1611
  throw this.#faultError(afterCommitFault, 'push_after_commit_before_response')
1302
1612
 
1303
1613
  const response = this.#wasm(() => engine_assemble_push_response(results))
1304
- const state = this.#engineState()
1614
+ const state = this.#engineStateBestEffort()
1305
1615
  this.#log({
1306
1616
  namespaceHash: namespace,
1307
1617
  requestKind: 'push',
@@ -1330,14 +1640,15 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1330
1640
  } catch (error) {
1331
1641
  const status = statusOf(error)
1332
1642
  if (status === 500) this.#counters.invariantFailures++
1643
+ const state = this.#engineStateBestEffort()
1333
1644
  this.#log({
1334
1645
  namespaceHash: namespace,
1335
1646
  requestKind: 'push',
1336
1647
  resultClass: 'error',
1337
1648
  inputCookie: null,
1338
1649
  outputCookie: null,
1339
- retainedFloor: this.#engineState()?.floor ?? null,
1340
- currentWatermark: this.#engineState()?.watermark ?? null,
1650
+ retainedFloor: state?.floor ?? null,
1651
+ currentWatermark: state?.watermark ?? null,
1341
1652
  changeRowsScanned: 0,
1342
1653
  changeRowsIncluded: 0,
1343
1654
  queriesRecomputed: 0,
@@ -1402,7 +1713,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1402
1713
  heapUsedBytes: heap?.usedJSHeapSize ?? null,
1403
1714
  heapTotalBytes: heap?.totalJSHeapSize ?? null,
1404
1715
  heapLimitBytes: heap?.jsHeapSizeLimit ?? null,
1405
- engine: this.#engineState(),
1716
+ engine: this.#engineStateBestEffort(),
1406
1717
  counters: this.#counters,
1407
1718
  ingestBreaker: this.#ingestBreaker.status(),
1408
1719
  })
@@ -1410,9 +1721,22 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1410
1721
  }
1411
1722
  if (route === '/admin/sql') {
1412
1723
  return request.json().then((body) => {
1413
- const { query } = body as { query?: string }
1724
+ const { params, query } = body as { params?: unknown; query?: string }
1414
1725
  if (typeof query !== 'string') return json({ error: 'query is required' }, 400)
1415
- return json({ rows: this.#directSql.query(query) })
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
+ }
1416
1740
  })
1417
1741
  }
1418
1742
  if (route === '/admin/invalidate') {
@@ -1427,14 +1751,14 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1427
1751
  }
1428
1752
  return (async () => {
1429
1753
  try {
1430
- const beforeUpstreamWatermark = this.#engineState()?.upstreamWatermark ?? null
1754
+ const beforeUpstreamWatermark = this.#engineState().upstreamWatermark
1431
1755
  const applied = await this.#ingest(upstreamPath, true)
1432
1756
  const engine = this.#engineState()
1433
1757
  return json({
1434
1758
  ok: true,
1435
1759
  applied,
1436
1760
  beforeUpstreamWatermark,
1437
- afterUpstreamWatermark: engine?.upstreamWatermark ?? null,
1761
+ afterUpstreamWatermark: engine.upstreamWatermark,
1438
1762
  engine,
1439
1763
  })
1440
1764
  } catch (error) {
@@ -1447,12 +1771,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1447
1771
  return json({ ok: true })
1448
1772
  }
1449
1773
  if (route === '/admin/restart') {
1450
- this.#bootID = crypto.randomUUID()
1451
- this.#hibernations++
1452
- this.#counters = freshCounters()
1453
- this.#pulling.clear()
1454
- this.#wakeOrigins.clear()
1455
- this.#wakeRecipients.clear()
1774
+ this.ctx.abort('admin requested durable object restart')
1456
1775
  return json({ ok: true, bootID: this.#bootID })
1457
1776
  }
1458
1777
  if (route === '/admin/visibility') {