@livestore/sync-cf 0.4.0 → 0.5.0-dev.1

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 (134) hide show
  1. package/README.md +5 -5
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
  4. package/dist/cf-worker/do/durable-object.js +53 -36
  5. package/dist/cf-worker/do/durable-object.js.map +1 -1
  6. package/dist/cf-worker/do/layer.d.ts +22 -18
  7. package/dist/cf-worker/do/layer.d.ts.map +1 -1
  8. package/dist/cf-worker/do/layer.js +45 -35
  9. package/dist/cf-worker/do/layer.js.map +1 -1
  10. package/dist/cf-worker/do/observability.d.ts +23 -0
  11. package/dist/cf-worker/do/observability.d.ts.map +1 -0
  12. package/dist/cf-worker/do/observability.js +52 -0
  13. package/dist/cf-worker/do/observability.js.map +1 -0
  14. package/dist/cf-worker/do/observability.test.d.ts +2 -0
  15. package/dist/cf-worker/do/observability.test.d.ts.map +1 -0
  16. package/dist/cf-worker/do/observability.test.js +204 -0
  17. package/dist/cf-worker/do/observability.test.js.map +1 -0
  18. package/dist/cf-worker/do/pull.d.ts +3 -3
  19. package/dist/cf-worker/do/pull.d.ts.map +1 -1
  20. package/dist/cf-worker/do/pull.js +19 -20
  21. package/dist/cf-worker/do/pull.js.map +1 -1
  22. package/dist/cf-worker/do/push.d.ts +16 -3
  23. package/dist/cf-worker/do/push.d.ts.map +1 -1
  24. package/dist/cf-worker/do/push.js +74 -53
  25. package/dist/cf-worker/do/push.js.map +1 -1
  26. package/dist/cf-worker/do/push.test.d.ts +2 -0
  27. package/dist/cf-worker/do/push.test.d.ts.map +1 -0
  28. package/dist/cf-worker/do/push.test.js +30 -0
  29. package/dist/cf-worker/do/push.test.js.map +1 -0
  30. package/dist/cf-worker/do/sqlite.d.ts +34 -46
  31. package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
  32. package/dist/cf-worker/do/sqlite.js +2 -2
  33. package/dist/cf-worker/do/sqlite.js.map +1 -1
  34. package/dist/cf-worker/do/sync-storage.d.ts +7 -6
  35. package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
  36. package/dist/cf-worker/do/sync-storage.js +29 -17
  37. package/dist/cf-worker/do/sync-storage.js.map +1 -1
  38. package/dist/cf-worker/do/transport/do-rpc-server.d.ts +17 -2
  39. package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
  40. package/dist/cf-worker/do/transport/do-rpc-server.js +41 -23
  41. package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
  42. package/dist/cf-worker/do/transport/do-rpc-server.test.d.ts +2 -0
  43. package/dist/cf-worker/do/transport/do-rpc-server.test.d.ts.map +1 -0
  44. package/dist/cf-worker/do/transport/do-rpc-server.test.js +28 -0
  45. package/dist/cf-worker/do/transport/do-rpc-server.test.js.map +1 -0
  46. package/dist/cf-worker/do/transport/http-rpc-server.d.ts +2 -2
  47. package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
  48. package/dist/cf-worker/do/transport/http-rpc-server.js +12 -12
  49. package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
  50. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +9 -2
  51. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
  52. package/dist/cf-worker/do/transport/ws-rpc-server.js +37 -29
  53. package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
  54. package/dist/cf-worker/mod.js +3 -3
  55. package/dist/cf-worker/shared.d.ts +37 -29
  56. package/dist/cf-worker/shared.d.ts.map +1 -1
  57. package/dist/cf-worker/shared.js +14 -12
  58. package/dist/cf-worker/shared.js.map +1 -1
  59. package/dist/cf-worker/worker.d.ts +9 -9
  60. package/dist/cf-worker/worker.d.ts.map +1 -1
  61. package/dist/cf-worker/worker.js +17 -15
  62. package/dist/cf-worker/worker.js.map +1 -1
  63. package/dist/client/mod.js +3 -3
  64. package/dist/client/transport/do-rpc-client.d.ts +18 -6
  65. package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
  66. package/dist/client/transport/do-rpc-client.js +112 -38
  67. package/dist/client/transport/do-rpc-client.js.map +1 -1
  68. package/dist/client/transport/do-rpc-client.test.d.ts +2 -0
  69. package/dist/client/transport/do-rpc-client.test.d.ts.map +1 -0
  70. package/dist/client/transport/do-rpc-client.test.js +268 -0
  71. package/dist/client/transport/do-rpc-client.test.js.map +1 -0
  72. package/dist/client/transport/http-rpc-client.d.ts +3 -3
  73. package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
  74. package/dist/client/transport/http-rpc-client.js +48 -29
  75. package/dist/client/transport/http-rpc-client.js.map +1 -1
  76. package/dist/client/transport/rpc-client-error.d.ts +5 -0
  77. package/dist/client/transport/rpc-client-error.d.ts.map +1 -0
  78. package/dist/client/transport/rpc-client-error.js +8 -0
  79. package/dist/client/transport/rpc-client-error.js.map +1 -0
  80. package/dist/client/transport/rpc-client-error.test.d.ts +2 -0
  81. package/dist/client/transport/rpc-client-error.test.d.ts.map +1 -0
  82. package/dist/client/transport/rpc-client-error.test.js +33 -0
  83. package/dist/client/transport/rpc-client-error.test.js.map +1 -0
  84. package/dist/client/transport/ws-rpc-client.d.ts +3 -3
  85. package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
  86. package/dist/client/transport/ws-rpc-client.js +33 -23
  87. package/dist/client/transport/ws-rpc-client.js.map +1 -1
  88. package/dist/common/do-rpc-schema.d.ts +53 -41
  89. package/dist/common/do-rpc-schema.d.ts.map +1 -1
  90. package/dist/common/do-rpc-schema.js +13 -4
  91. package/dist/common/do-rpc-schema.js.map +1 -1
  92. package/dist/common/http-rpc-schema.d.ts +38 -39
  93. package/dist/common/http-rpc-schema.d.ts.map +1 -1
  94. package/dist/common/http-rpc-schema.js +6 -6
  95. package/dist/common/http-rpc-schema.js.map +1 -1
  96. package/dist/common/mod.d.ts +5 -5
  97. package/dist/common/mod.d.ts.map +1 -1
  98. package/dist/common/mod.js +7 -7
  99. package/dist/common/mod.js.map +1 -1
  100. package/dist/common/sync-message-types.d.ts +116 -133
  101. package/dist/common/sync-message-types.d.ts.map +1 -1
  102. package/dist/common/sync-message-types.js +26 -14
  103. package/dist/common/sync-message-types.js.map +1 -1
  104. package/dist/common/ws-rpc-schema.d.ts +33 -34
  105. package/dist/common/ws-rpc-schema.d.ts.map +1 -1
  106. package/dist/common/ws-rpc-schema.js +5 -5
  107. package/dist/common/ws-rpc-schema.js.map +1 -1
  108. package/package.json +42 -58
  109. package/src/cf-worker/do/durable-object.ts +70 -64
  110. package/src/cf-worker/do/layer.ts +142 -99
  111. package/src/cf-worker/do/observability.test.ts +337 -0
  112. package/src/cf-worker/do/observability.ts +121 -0
  113. package/src/cf-worker/do/pull.ts +28 -28
  114. package/src/cf-worker/do/push.test.ts +40 -0
  115. package/src/cf-worker/do/push.ts +139 -115
  116. package/src/cf-worker/do/sqlite.ts +1 -1
  117. package/src/cf-worker/do/sync-storage.ts +34 -21
  118. package/src/cf-worker/do/transport/do-rpc-server.test.ts +32 -0
  119. package/src/cf-worker/do/transport/do-rpc-server.ts +46 -35
  120. package/src/cf-worker/do/transport/http-rpc-server.ts +7 -10
  121. package/src/cf-worker/do/transport/ws-rpc-server.ts +52 -28
  122. package/src/cf-worker/shared.ts +29 -17
  123. package/src/cf-worker/worker.ts +21 -22
  124. package/src/client/transport/do-rpc-client.test.ts +411 -0
  125. package/src/client/transport/do-rpc-client.ts +175 -51
  126. package/src/client/transport/http-rpc-client.ts +66 -45
  127. package/src/client/transport/rpc-client-error.test.ts +41 -0
  128. package/src/client/transport/rpc-client-error.ts +9 -0
  129. package/src/client/transport/ws-rpc-client.ts +40 -32
  130. package/src/common/do-rpc-schema.ts +13 -3
  131. package/src/common/http-rpc-schema.ts +5 -5
  132. package/src/common/mod.ts +4 -4
  133. package/src/common/sync-message-types.ts +16 -16
  134. package/src/common/ws-rpc-schema.ts +4 -4
@@ -0,0 +1,411 @@
1
+ import { BackendIdMismatchError, IsOfflineError, ServerAheadError, SyncBackend } from '@livestore/common'
2
+ import { type CfTypes, toDurableObjectHandler } from '@livestore/common-cf'
3
+ import { EventSequenceNumber } from '@livestore/common/schema'
4
+ import { Vitest } from '@livestore/utils-dev/node-vitest'
5
+ import {
6
+ Cause,
7
+ Clock,
8
+ Deferred,
9
+ Duration,
10
+ Effect,
11
+ Exit,
12
+ FetchHttpClient,
13
+ Fiber,
14
+ KeyValueStore,
15
+ Layer,
16
+ Option,
17
+ Stream,
18
+ TestClock,
19
+ } from '@livestore/utils/effect'
20
+
21
+ import { SyncDoRpc } from '../../common/do-rpc-schema.ts'
22
+ import { makeDoRpcSync, type SyncBackendRpcStub } from './do-rpc-client.ts'
23
+
24
+ Vitest.describe('DO-RPC sync client transport failures', () => {
25
+ Vitest.effect('a retryable rejection fails the pull as offline after a backoff', () =>
26
+ Effect.gen(function* () {
27
+ const { backend } = yield* makeBackend([rejectWith(cloudflareError({ retryable: true }))])
28
+
29
+ const { exit, delays } = yield* runRecordingBackoff(backend.pull(Option.none()).pipe(Stream.runCollect))
30
+
31
+ Vitest.expect(failureOf(exit)).toBeInstanceOf(IsOfflineError)
32
+ expectBackoff(delays[0]!, 1_000)
33
+ }),
34
+ )
35
+
36
+ Vitest.effect('a retryable push rejection fails as offline', () =>
37
+ Effect.gen(function* () {
38
+ const { backend } = yield* makeBackend([rejectWith(cloudflareError({ retryable: true }))])
39
+
40
+ const { exit } = yield* runRecordingBackoff(backend.push([event]))
41
+
42
+ Vitest.expect(failureOf(exit)).toBeInstanceOf(IsOfflineError)
43
+ }),
44
+ )
45
+
46
+ Vitest.effect('a retryable ping rejection fails as offline', () =>
47
+ Effect.gen(function* () {
48
+ const { backend } = yield* makeBackend([rejectWith(cloudflareError({ retryable: true }))])
49
+
50
+ const { exit } = yield* runRecordingBackoff(backend.ping)
51
+
52
+ Vitest.expect(failureOf(exit)).toBeInstanceOf(IsOfflineError)
53
+ }),
54
+ )
55
+
56
+ Vitest.describe('keeps terminal failures terminal', () => {
57
+ // Overload wins over `retryable`, and `remote` alone does not mean retryable (infrastructure errors can carry it).
58
+ const terminal = [
59
+ { name: 'overloaded and retryable', flags: { overloaded: true, retryable: true } },
60
+ { name: 'remote only', flags: { remote: true } },
61
+ { name: 'unflagged', flags: {} },
62
+ ]
63
+
64
+ for (const { name, flags } of terminal) {
65
+ Vitest.effect(`${name}: pull dies with the original error without waiting`, () =>
66
+ Effect.gen(function* () {
67
+ const rejected = cloudflareError(flags)
68
+ const { backend } = yield* makeBackend([rejectWith(rejected)])
69
+
70
+ const { exit, delays } = yield* runRecordingBackoff(backend.pull(Option.none()).pipe(Stream.runCollect))
71
+
72
+ Vitest.expect(defectOf(exit)).toBe(rejected)
73
+ Vitest.expect(delays).toEqual([])
74
+ }),
75
+ )
76
+
77
+ Vitest.effect(`${name}: push dies with the original error`, () =>
78
+ Effect.gen(function* () {
79
+ const rejected = cloudflareError(flags)
80
+ const { backend } = yield* makeBackend([rejectWith(rejected)])
81
+
82
+ const { exit } = yield* runRecordingBackoff(backend.push([event]))
83
+
84
+ Vitest.expect(defectOf(exit)).toBe(rejected)
85
+ }),
86
+ )
87
+ }
88
+ })
89
+
90
+ Vitest.describe('passes application errors through unchanged', () => {
91
+ Vitest.effect('pull: BackendIdMismatchError', () =>
92
+ Effect.gen(function* () {
93
+ const { backend } = yield* makeBackend([
94
+ serve({ pull: () => Stream.fail(new BackendIdMismatchError({ expected: 'a', received: 'b' })) }),
95
+ ])
96
+
97
+ const { exit } = yield* runRecordingBackoff(backend.pull(Option.none()).pipe(Stream.runCollect))
98
+
99
+ Vitest.expect(failureOf(exit)).toBeInstanceOf(BackendIdMismatchError)
100
+ }),
101
+ )
102
+
103
+ Vitest.effect('push: ServerAheadError', () =>
104
+ Effect.gen(function* () {
105
+ const { backend } = yield* makeBackend([
106
+ serve({
107
+ push: () =>
108
+ Effect.fail(
109
+ new ServerAheadError({
110
+ minimumExpectedNum: EventSequenceNumber.Global.make(2),
111
+ providedNum: EventSequenceNumber.Global.make(1),
112
+ }),
113
+ ),
114
+ }),
115
+ ])
116
+
117
+ const { exit } = yield* runRecordingBackoff(backend.push([event]))
118
+
119
+ Vitest.expect(failureOf(exit)).toBeInstanceOf(ServerAheadError)
120
+ }),
121
+ )
122
+ })
123
+
124
+ Vitest.describe('backoff', () => {
125
+ Vitest.effect('grows to the cap across pulls that each deliver a page and then fail', () =>
126
+ Effect.gen(function* () {
127
+ const failure = pageThenRejectWith(cloudflareError({ retryable: true }))
128
+ const expectedDelays = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000]
129
+ const { backend } = yield* makeBackend(expectedDelays.map(() => failure))
130
+
131
+ for (const expectedDelay of expectedDelays) {
132
+ const { exit, delays } = yield* runRecordingBackoff(backend.pull(Option.none()).pipe(Stream.runCollect))
133
+ Vitest.expect(failureOf(exit)).toBeInstanceOf(IsOfflineError)
134
+ Vitest.expect(delays).toHaveLength(1)
135
+ expectBackoff(delays[0]!, expectedDelay)
136
+ }
137
+ }),
138
+ )
139
+
140
+ Vitest.effect('interrupts a pull while it is waiting to retry', () =>
141
+ Effect.gen(function* () {
142
+ const { backend } = yield* makeBackend([rejectWith(cloudflareError({ retryable: true }))])
143
+ const clock = yield* Clock.Clock
144
+ const waiting = yield* Deferred.make<void>()
145
+ const fiber = yield* backend.pull(Option.none()).pipe(
146
+ Stream.runCollect,
147
+ Effect.provideService(Clock.Clock, {
148
+ ...clock,
149
+ sleep: (duration) => {
150
+ if (Duration.toMillis(duration) >= 60_000) return clock.sleep(duration)
151
+ return Deferred.succeed(waiting, undefined).pipe(Effect.andThen(clock.sleep(duration)))
152
+ },
153
+ }),
154
+ Effect.forkChild,
155
+ )
156
+
157
+ yield* Deferred.await(waiting)
158
+ Vitest.expect(fiber.pollUnsafe()).toBeUndefined()
159
+ yield* Fiber.interrupt(fiber)
160
+ Vitest.expect(Exit.hasInterrupts(yield* Fiber.await(fiber))).toBe(true)
161
+ }),
162
+ )
163
+
164
+ Vitest.effect('resets once a pull completes its catch-up', () =>
165
+ Effect.gen(function* () {
166
+ const failure = rejectWith(cloudflareError({ retryable: true }))
167
+ const { backend } = yield* makeBackend([
168
+ failure,
169
+ failure,
170
+ serve({ pull: () => Stream.make(pullResponse(SyncBackend.pageInfoNoMore)) }),
171
+ failure,
172
+ ])
173
+ const pull = runRecordingBackoff(backend.pull(Option.none()).pipe(Stream.runCollect))
174
+
175
+ yield* pull
176
+ yield* pull
177
+ const completed = yield* pull
178
+ const afterReset = yield* pull
179
+
180
+ Vitest.expect(Exit.isSuccess(completed.exit)).toBe(true)
181
+ expectBackoff(afterReset.delays[0]!, 1_000)
182
+ }),
183
+ )
184
+ })
185
+
186
+ Vitest.effect('recovers on a fresh stub after a stub breaks', () =>
187
+ Effect.gen(function* () {
188
+ const broken = rejectWith(cloudflareError({ retryable: true }))
189
+ const healthy = serve({ pull: () => Stream.make(pullResponse(SyncBackend.pageInfoNoMore)) })
190
+ const { backend } = yield* makeBackend([broken, healthy])
191
+
192
+ const first = yield* runRecordingBackoff(backend.pull(Option.none()).pipe(Stream.runCollect))
193
+ const second = yield* runRecordingBackoff(backend.pull(Option.none()).pipe(Stream.runCollect))
194
+
195
+ Vitest.expect(failureOf(first.exit)).toBeInstanceOf(IsOfflineError)
196
+ Vitest.expect(Exit.isSuccess(second.exit)).toBe(true)
197
+ }),
198
+ )
199
+
200
+ Vitest.effect('a stalled catch-up releases its producer and recovers on a fresh stub', () =>
201
+ Effect.gen(function* () {
202
+ const pageSeen = yield* Deferred.make<void>()
203
+ const cancelled = yield* Deferred.make<void>()
204
+ const { backend } = yield* makeBackend([
205
+ serve({
206
+ pull: () =>
207
+ Stream.make(pullResponse(SyncBackend.pageInfoMoreUnknown)).pipe(
208
+ Stream.concat(Stream.never),
209
+ Stream.ensuring(Deferred.succeed(cancelled, undefined)),
210
+ ),
211
+ }),
212
+ serve({ pull: () => Stream.make(pullResponse(SyncBackend.pageInfoNoMore)) }),
213
+ ])
214
+ const fiber = yield* backend.pull(Option.none()).pipe(
215
+ Stream.tap(() => Deferred.succeed(pageSeen, undefined)),
216
+ Stream.runCollect,
217
+ Effect.exit,
218
+ Effect.forkChild,
219
+ )
220
+ yield* Deferred.await(pageSeen)
221
+ yield* TestClock.adjust('62 seconds')
222
+ Vitest.expect(failureOf(yield* Fiber.join(fiber))).toBeInstanceOf(IsOfflineError)
223
+ yield* Deferred.await(cancelled)
224
+ const recovered = yield* backend.pull(Option.none()).pipe(Stream.runCollect)
225
+ Vitest.expect(recovered).toHaveLength(1)
226
+ }),
227
+ )
228
+
229
+ Vitest.effect('a live pull can remain idle after catch-up without reconnecting', () =>
230
+ Effect.gen(function* () {
231
+ const caughtUp = yield* Deferred.make<void>()
232
+ const healthy = serve({ pull: () => Stream.make(pullResponse(SyncBackend.pageInfoNoMore)) })
233
+ const { backend } = yield* makeBackend([healthy, healthy]) // second call is unsubscribe on interruption
234
+ const fiber = yield* backend.pull(Option.none(), { live: true }).pipe(
235
+ Stream.tap(() => Deferred.succeed(caughtUp, undefined)),
236
+ Stream.runDrain,
237
+ Effect.forkChild,
238
+ )
239
+ yield* Deferred.await(caughtUp)
240
+ yield* TestClock.adjust('2 minutes')
241
+ Vitest.expect(fiber.pollUnsafe()).toBeUndefined()
242
+ yield* Fiber.interrupt(fiber)
243
+ }),
244
+ )
245
+
246
+ Vitest.effect('the catch-up deadline restarts for each page', () =>
247
+ Effect.gen(function* () {
248
+ const sendFirst = yield* Deferred.make<void>()
249
+ const sendLast = yield* Deferred.make<void>()
250
+ const firstSeen = yield* Deferred.make<void>()
251
+ const { backend } = yield* makeBackend([
252
+ serve({
253
+ pull: () =>
254
+ Stream.fromEffect(Deferred.await(sendFirst)).pipe(
255
+ Stream.map(() => pullResponse(SyncBackend.pageInfoMoreUnknown)),
256
+ Stream.concat(
257
+ Stream.fromEffect(Deferred.await(sendLast)).pipe(
258
+ Stream.map(() => pullResponse(SyncBackend.pageInfoNoMore)),
259
+ ),
260
+ ),
261
+ ),
262
+ }),
263
+ ])
264
+ const fiber = yield* backend.pull(Option.none()).pipe(
265
+ Stream.tap(() => Deferred.succeed(firstSeen, undefined)),
266
+ Stream.runCollect,
267
+ Effect.forkChild,
268
+ )
269
+ yield* TestClock.adjust('40 seconds')
270
+ yield* Deferred.succeed(sendFirst, undefined)
271
+ yield* Deferred.await(firstSeen)
272
+ yield* TestClock.adjust('40 seconds')
273
+ yield* Deferred.succeed(sendLast, undefined)
274
+ Vitest.expect(yield* Fiber.join(fiber)).toHaveLength(2)
275
+ }),
276
+ )
277
+
278
+ Vitest.effect('overload on a stream read remains terminal', () =>
279
+ Effect.gen(function* () {
280
+ const error = cloudflareError({ overloaded: true, retryable: true })
281
+ const { backend } = yield* makeBackend([pageThenRejectWith(error)])
282
+ const { exit, delays } = yield* runRecordingBackoff(backend.pull(Option.none()).pipe(Stream.runCollect))
283
+ Vitest.expect(defectOf(exit)).toBe(error)
284
+ Vitest.expect(delays).toEqual([])
285
+ }),
286
+ )
287
+ })
288
+
289
+ type CallRpc = (payload: Uint8Array) => Promise<Uint8Array | CfTypes.ReadableStream>
290
+
291
+ const backendId = 'backend'
292
+
293
+ const event = {
294
+ name: 'todoCreated',
295
+ args: {},
296
+ seqNum: EventSequenceNumber.Global.make(1),
297
+ parentSeqNum: EventSequenceNumber.Global.make(0),
298
+ clientId: 'client',
299
+ sessionId: 'session',
300
+ }
301
+
302
+ const pullResponse = (pageInfo: SyncBackend.PullResPageInfo) => ({
303
+ rpcRequestId: '0',
304
+ batch: [{ eventEncoded: event, metadata: Option.none() }],
305
+ pageInfo,
306
+ backendId,
307
+ })
308
+
309
+ /** A Cloudflare DO error carries runtime flags as own properties on the thrown `Error`. */
310
+ const cloudflareError = (flags: { retryable?: boolean; overloaded?: boolean; remote?: boolean }) =>
311
+ Object.assign(new Error('Network connection lost.'), flags)
312
+
313
+ const rejectWith =
314
+ (error: Error): CallRpc =>
315
+ () =>
316
+ Promise.reject(error)
317
+
318
+ /** Serves the real handler, so each call carries encoded responses exactly as a backend DO sends them. */
319
+ const serve =
320
+ (handlers: {
321
+ pull?: () => Stream.Stream<ReturnType<typeof pullResponse>, BackendIdMismatchError>
322
+ push?: () => Effect.Effect<{}, ServerAheadError>
323
+ }): CallRpc =>
324
+ (payload) =>
325
+ toDurableObjectHandler(SyncDoRpc, {
326
+ layer: SyncDoRpc.toLayer({
327
+ 'SyncDoRpc.Pull': () => handlers.pull?.() ?? Stream.empty,
328
+ 'SyncDoRpc.Push': () => handlers.push?.() ?? Effect.succeed({}),
329
+ 'SyncDoRpc.Ping': () => Effect.void,
330
+ 'SyncDoRpc.Unsubscribe': () => Effect.void,
331
+ }),
332
+ })(new Uint8Array(payload)).pipe(Effect.runPromise)
333
+
334
+ /** Delivers one page, then rejects the next stream read with `error`. */
335
+ const pageThenRejectWith =
336
+ (error: Error): CallRpc =>
337
+ async (payload) => {
338
+ const response = await serve({
339
+ pull: () => Stream.make(pullResponse(SyncBackend.pageInfoMoreUnknown)),
340
+ })(payload)
341
+ if (response instanceof Uint8Array) return response
342
+ const source = response.getReader()
343
+ // Keep only the page frame; replace the normal RPC exit with a transport failure.
344
+ const { value } = await source.read()
345
+ await source.cancel()
346
+ source.releaseLock()
347
+ let sentPage = false
348
+ const stream = new ReadableStream<Uint8Array>({
349
+ pull(controller) {
350
+ if (sentPage === true) return controller.error(error)
351
+ sentPage = true
352
+ controller.enqueue(value)
353
+ },
354
+ })
355
+ // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- bridge platform ReadableStream to the CF type
356
+ return stream as unknown as CfTypes.ReadableStream
357
+ }
358
+
359
+ /** Each supplied RPC implementation belongs to one stub, and stays attached if that stub is reused. */
360
+ const makeBackend = (stubs: ReadonlyArray<CallRpc>) =>
361
+ Effect.gen(function* () {
362
+ let nextStub = 0
363
+ const backend = yield* makeDoRpcSync({
364
+ getSyncBackendStub: () => {
365
+ const rpc = stubs[nextStub++]
366
+ if (rpc === undefined) throw new Error('Unexpected extra stub')
367
+ // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- a test double only needs `rpc`
368
+ return { rpc } as unknown as SyncBackendRpcStub
369
+ },
370
+ // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- only live pulls read the DO state
371
+ durableObjectState: {} as CfTypes.DurableObjectState,
372
+ durableObjectContext: { bindingName: 'CLIENT_DO', durableObjectId: 'client-do' },
373
+ })({ storeId: 'store', clientId: 'client', payload: undefined }).pipe(
374
+ Effect.provide(Layer.mergeAll(FetchHttpClient.layer, KeyValueStore.layerMemory)),
375
+ )
376
+
377
+ return { backend }
378
+ })
379
+
380
+ /** Observe the waits requested by production code without polling fibers or waiting on wall-clock timers. */
381
+ const runRecordingBackoff = <A, E>(effect: Effect.Effect<A, E>) =>
382
+ Effect.gen(function* () {
383
+ const clock = yield* Clock.Clock
384
+ const delays: number[] = []
385
+ const exit = yield* effect.pipe(
386
+ Effect.provideService(Clock.Clock, {
387
+ ...clock,
388
+ sleep: (duration) => {
389
+ if (Duration.toMillis(duration) >= 60_000) return clock.sleep(duration)
390
+ return Effect.sync(() => {
391
+ delays.push(Duration.toMillis(duration))
392
+ })
393
+ },
394
+ }),
395
+ Effect.exit,
396
+ )
397
+ return { exit, delays }
398
+ })
399
+
400
+ const expectBackoff = (delay: number, baseMs: number) => {
401
+ Vitest.expect(delay).toBeGreaterThanOrEqual(baseMs * 0.8)
402
+ Vitest.expect(delay).toBeLessThanOrEqual(baseMs * 1.2)
403
+ }
404
+
405
+ const failureOf = (exit: Exit.Exit<unknown, unknown>): unknown =>
406
+ Exit.isFailure(exit) === true ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined
407
+
408
+ const defectOf = (exit: Exit.Exit<unknown, unknown>): unknown =>
409
+ Exit.isFailure(exit) === true && Option.isNone(Cause.findErrorOption(exit.cause)) === true
410
+ ? Cause.squash(exit.cause)
411
+ : undefined