orez-sync-cf-host 0.5.17 → 0.5.18

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
@@ -2,11 +2,14 @@ import { DurableObject } from 'cloudflare:workers'
2
2
 
3
3
  import { validatePullCaps, validateSyncHostConfig } from './config.js'
4
4
  import {
5
+ engine_apply_snapshot_changes,
6
+ engine_apply_snapshot_page,
5
7
  engine_apply_upstream,
6
- engine_apply_upstream_snapshot,
7
8
  engine_assemble_push_response,
9
+ engine_begin_snapshot_generation,
8
10
  engine_compile_query,
9
11
  engine_finalize,
12
+ engine_finalize_snapshot_generation,
10
13
  engine_handle_pull,
11
14
  engine_handle_query_pull,
12
15
  engine_init_query_schema,
@@ -16,6 +19,7 @@ import {
16
19
  engine_preflight,
17
20
  engine_prune,
18
21
  engine_push_validate,
22
+ engine_read_snapshot_progress,
19
23
  engine_record_app_error,
20
24
  engine_state,
21
25
  engine_version,
@@ -49,6 +53,8 @@ initSync({ module: wasmModule })
49
53
  const CLAIMS_HEADER = 'x-orez-sync-claims'
50
54
  const NAMESPACE_HEADER = 'x-orez-sync-namespace'
51
55
  const UPSTREAM_PATH_HEADER = 'x-orez-sync-upstream-path'
56
+ const DEFAULT_SNAPSHOT_PAGE_ROWS = 2_000
57
+ const MIN_SNAPSHOT_PAGE_ROWS = 100
52
58
  const DEFAULT_CAPS: PullCaps = {
53
59
  maxChangeRows: 10_000,
54
60
  maxChangeBytes: 2_000_000,
@@ -96,6 +102,19 @@ type ApplyUpstreamResult = {
96
102
  applied: number
97
103
  caughtUp: boolean
98
104
  }
105
+ type SnapshotProgress = {
106
+ generation: string
107
+ startWatermark: string
108
+ table: string | null
109
+ cursor: string | null
110
+ state: 'paging' | 'catching_up'
111
+ catchupWatermark: string
112
+ }
113
+ type SnapshotPage = {
114
+ watermark: number
115
+ rows: Record<string, unknown>[]
116
+ nextCursor: string | null
117
+ }
99
118
  type SocketAttachment = { clientID: string }
100
119
  type FaultPoint =
101
120
  | 'push_before_mutation'
@@ -253,7 +272,15 @@ export function createSyncWorker<Env extends SyncHostEnv>(
253
272
 
254
273
  const route = routeAfterNamespace(new URL(request.url).pathname)
255
274
  const isAdmin = route.startsWith('/admin/')
256
- if (isAdmin) {
275
+ if (route === '/wake') {
276
+ if (!(await config.authorizeWake(request, env))) {
277
+ return json({ error: 'missing wake capability' }, 401)
278
+ }
279
+ } else if (route === '/notify') {
280
+ if (!(await config.authorizeNotify(request, env))) {
281
+ return json({ error: 'forbidden' }, 403)
282
+ }
283
+ } else if (isAdmin) {
257
284
  const authorized = config.authorizeAdmin
258
285
  ? await config.authorizeAdmin(request, env)
259
286
  : Boolean(env.ADMIN_KEY) && request.headers.get('x-admin-key') === env.ADMIN_KEY
@@ -264,10 +291,6 @@ export function createSyncWorker<Env extends SyncHostEnv>(
264
291
  headers.delete(CLAIMS_HEADER)
265
292
  headers.delete(NAMESPACE_HEADER)
266
293
  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
294
  if (!isAdmin && route !== '/wake' && route !== '/notify') {
272
295
  const claims = await config.authenticate(request, env)
273
296
  if (!claims) return json({ error: 'missing authentication' }, 401)
@@ -328,6 +351,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
328
351
  #wakeRecipients = new Set<WebSocket>()
329
352
  #wakePromise: Promise<void> | null = null
330
353
  #ingestPromise: Promise<number> | null = null
354
+ #queryPullLocks = new Map<string, Promise<void>>()
331
355
  #recordingIngestBillable = false
332
356
  #ingestBreaker = new IngestCircuitBreaker({
333
357
  budgetRows: ingestBudgetRows,
@@ -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
@@ -905,84 +1197,94 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
905
1197
  if (!Number.isSafeInteger(transformVersion) || transformVersion < 0) {
906
1198
  throw new TypeError('queryTransformVersion must be a non-negative safe integer')
907
1199
  }
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}`)
1200
+ const releaseQueryPull =
1201
+ queryAware && config.resolveQuery
1202
+ ? await this.#acquireQueryPullLock(
1203
+ typeof body.clientGroupID === 'string' ? body.clientGroupID : ''
1204
+ )
1205
+ : null
1206
+ let response: Record<string, unknown>
1207
+ try {
1208
+ if (queryAware && body.queries) {
1209
+ const queries = body.queries as {
1210
+ version?: unknown
1211
+ patch?: unknown
1212
+ }
1213
+ if (Array.isArray(queries.patch)) {
1214
+ const patch = []
1215
+ for (const operation of queries.patch) {
1216
+ if (!operation || typeof operation !== 'object') {
1217
+ patch.push(operation)
1218
+ continue
939
1219
  }
940
- patch.push({
941
- op: 'put',
942
- hash: op.hash,
943
- ast,
944
- transformVersion,
945
- })
946
- } else patch.push(operation)
1220
+ const op = operation as Record<string, unknown>
1221
+ if (op.op === 'put') {
1222
+ if (!config.resolveQuery || typeof op.name !== 'string') {
1223
+ throw requestError('query put requires a server-resolved named query')
1224
+ }
1225
+ if (!Array.isArray(op.args)) {
1226
+ throw requestError('named query args must be an array')
1227
+ }
1228
+ const args = op.args as JsonValue[]
1229
+ let ast: JsonValue
1230
+ try {
1231
+ // resolveQuery may be async and needs `env` (a consumer can
1232
+ // delegate the transform to its app's real synced-queries
1233
+ // endpoint over an app service binding — authenticate runs in the
1234
+ // worker isolate, but the query loop runs here in the DO, so the
1235
+ // binding must come from the DO's own env, not a shared global).
1236
+ ast = await config.resolveQuery(op.name, args, claims, this.env)
1237
+ } catch (error) {
1238
+ throw requestError(`unknown or unsupported named query: ${op.name}`)
1239
+ }
1240
+ patch.push({
1241
+ op: 'put',
1242
+ hash: op.hash,
1243
+ ast,
1244
+ transformVersion,
1245
+ })
1246
+ } else patch.push(operation)
1247
+ }
1248
+ body = { ...body, queries: { ...queries, patch } }
947
1249
  }
948
- body = { ...body, queries: { ...queries, patch } }
949
1250
  }
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
1251
+ if (queryAware) {
1252
+ body = { ...body, _serverQueryTransformVersion: transformVersion }
1253
+ }
1254
+ const clientID = typeof body.clientID === 'string' ? body.clientID : ''
1255
+ this.#pulling.add(clientID)
1256
+ try {
1257
+ const txStarted = performance.now()
1258
+ const duringFault = this.#takeFault('pull_during_tx')
1259
+ response = this.ctx.storage.transactionSync(() => {
1260
+ const result = this.#wasm(() =>
1261
+ queryAware
1262
+ ? engine_handle_query_pull(
1263
+ this.#engineDb,
1264
+ config.schema,
1265
+ this.#retainChanges(),
1266
+ body,
1267
+ claims.userID
1268
+ )
1269
+ : engine_handle_pull(
1270
+ this.#engineDb,
1271
+ config.schema,
1272
+ this.#visibility(claims),
1273
+ caps,
1274
+ this.#retainChanges(),
1275
+ body,
1276
+ claims.userID
1277
+ )
1278
+ ) as Record<string, unknown>
1279
+ if (duringFault) throw this.#faultError(duringFault, 'pull_during_tx')
1280
+ return result
1281
+ })
1282
+ transactionMs = performance.now() - txStarted
1283
+ } finally {
1284
+ this.#pulling.delete(clientID)
1285
+ }
984
1286
  } finally {
985
- this.#pulling.delete(clientID)
1287
+ releaseQueryPull?.()
986
1288
  }
987
1289
  const afterPullFault = this.#takeFault('pull_after_commit')
988
1290
  if (afterPullFault) throw this.#faultError(afterPullFault, 'pull_after_commit')
@@ -995,7 +1297,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
995
1297
  ).length
996
1298
  : 0
997
1299
  this.#counters.queryRecompilations += queryPuts
998
- const state = this.#engineState()
1300
+ const state = this.#engineStateBestEffort()
999
1301
  this.#log({
1000
1302
  namespaceHash: namespace,
1001
1303
  requestKind: 'pull',
@@ -1021,14 +1323,15 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1021
1323
  const status = statusOf(error)
1022
1324
  if (status === 409) this.#counters.resets++
1023
1325
  if (status === 500) this.#counters.invariantFailures++
1326
+ const state = this.#engineStateBestEffort()
1024
1327
  this.#log({
1025
1328
  namespaceHash: namespace,
1026
1329
  requestKind: 'pull',
1027
1330
  resultClass: status === 409 ? 'reset' : 'error',
1028
1331
  inputCookie: body?.cookie ?? null,
1029
1332
  outputCookie: null,
1030
- retainedFloor: this.#engineState()?.floor ?? null,
1031
- currentWatermark: this.#engineState()?.watermark ?? null,
1333
+ retainedFloor: state?.floor ?? null,
1334
+ currentWatermark: state?.watermark ?? null,
1032
1335
  changeRowsScanned: null,
1033
1336
  changeRowsIncluded: 0,
1034
1337
  queriesRecomputed: 0,
@@ -1059,7 +1362,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1059
1362
  // Workerd requires the request stream to be consumed before the DO
1060
1363
  // returns a response. Discard it without parsing or logging payloads.
1061
1364
  await request.arrayBuffer()
1062
- const state = this.#engineState()
1365
+ const state = this.#engineStateBestEffort()
1063
1366
  this.#log({
1064
1367
  namespaceHash: namespace,
1065
1368
  requestKind: 'push',
@@ -1094,7 +1397,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1094
1397
 
1095
1398
  const endpoint = new URL(
1096
1399
  `${upstreamPath ?? ''}${config.mutateUrl}`,
1097
- 'https://upstream.invalid'
1400
+ config.mutateOrigin ?? 'https://upstream.invalid'
1098
1401
  )
1099
1402
  const headers = new Headers(request.headers)
1100
1403
  headers.delete(CLAIMS_HEADER)
@@ -1105,7 +1408,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1105
1408
  endpoint,
1106
1409
  headers,
1107
1410
  bytes,
1108
- this.#engineState()?.upstreamWatermark === '0'
1411
+ this.#engineState().upstreamWatermark === '0'
1109
1412
  )
1110
1413
  if (!upstreamResponse.ok) {
1111
1414
  return new Response(upstreamResponse.body, upstreamResponse)
@@ -1301,7 +1604,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1301
1604
  throw this.#faultError(afterCommitFault, 'push_after_commit_before_response')
1302
1605
 
1303
1606
  const response = this.#wasm(() => engine_assemble_push_response(results))
1304
- const state = this.#engineState()
1607
+ const state = this.#engineStateBestEffort()
1305
1608
  this.#log({
1306
1609
  namespaceHash: namespace,
1307
1610
  requestKind: 'push',
@@ -1330,14 +1633,15 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1330
1633
  } catch (error) {
1331
1634
  const status = statusOf(error)
1332
1635
  if (status === 500) this.#counters.invariantFailures++
1636
+ const state = this.#engineStateBestEffort()
1333
1637
  this.#log({
1334
1638
  namespaceHash: namespace,
1335
1639
  requestKind: 'push',
1336
1640
  resultClass: 'error',
1337
1641
  inputCookie: null,
1338
1642
  outputCookie: null,
1339
- retainedFloor: this.#engineState()?.floor ?? null,
1340
- currentWatermark: this.#engineState()?.watermark ?? null,
1643
+ retainedFloor: state?.floor ?? null,
1644
+ currentWatermark: state?.watermark ?? null,
1341
1645
  changeRowsScanned: 0,
1342
1646
  changeRowsIncluded: 0,
1343
1647
  queriesRecomputed: 0,
@@ -1402,7 +1706,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1402
1706
  heapUsedBytes: heap?.usedJSHeapSize ?? null,
1403
1707
  heapTotalBytes: heap?.totalJSHeapSize ?? null,
1404
1708
  heapLimitBytes: heap?.jsHeapSizeLimit ?? null,
1405
- engine: this.#engineState(),
1709
+ engine: this.#engineStateBestEffort(),
1406
1710
  counters: this.#counters,
1407
1711
  ingestBreaker: this.#ingestBreaker.status(),
1408
1712
  })
@@ -1412,7 +1716,17 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1412
1716
  return request.json().then((body) => {
1413
1717
  const { query } = body as { query?: string }
1414
1718
  if (typeof query !== 'string') return json({ error: 'query is required' }, 400)
1415
- return json({ rows: this.#directSql.query(query) })
1719
+ try {
1720
+ return json({ rows: this.#directSql.query(query) })
1721
+ } catch (error) {
1722
+ if (
1723
+ error instanceof TypeError &&
1724
+ error.message === 'transaction SQL is host-owned and forbidden'
1725
+ ) {
1726
+ return json({ error: error.message }, 400)
1727
+ }
1728
+ throw error
1729
+ }
1416
1730
  })
1417
1731
  }
1418
1732
  if (route === '/admin/invalidate') {
@@ -1427,14 +1741,14 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1427
1741
  }
1428
1742
  return (async () => {
1429
1743
  try {
1430
- const beforeUpstreamWatermark = this.#engineState()?.upstreamWatermark ?? null
1744
+ const beforeUpstreamWatermark = this.#engineState().upstreamWatermark
1431
1745
  const applied = await this.#ingest(upstreamPath, true)
1432
1746
  const engine = this.#engineState()
1433
1747
  return json({
1434
1748
  ok: true,
1435
1749
  applied,
1436
1750
  beforeUpstreamWatermark,
1437
- afterUpstreamWatermark: engine?.upstreamWatermark ?? null,
1751
+ afterUpstreamWatermark: engine.upstreamWatermark,
1438
1752
  engine,
1439
1753
  })
1440
1754
  } catch (error) {
@@ -1447,12 +1761,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1447
1761
  return json({ ok: true })
1448
1762
  }
1449
1763
  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()
1764
+ this.ctx.abort('admin requested durable object restart')
1456
1765
  return json({ ok: true, bootID: this.#bootID })
1457
1766
  }
1458
1767
  if (route === '/admin/visibility') {