@livestore/sync-cf 0.5.0-dev.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 (101) 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 +34 -30
  5. package/dist/cf-worker/do/durable-object.js.map +1 -1
  6. package/dist/cf-worker/do/layer.d.ts.map +1 -1
  7. package/dist/cf-worker/do/layer.js +2 -2
  8. package/dist/cf-worker/do/observability.d.ts +23 -0
  9. package/dist/cf-worker/do/observability.d.ts.map +1 -0
  10. package/dist/cf-worker/do/observability.js +52 -0
  11. package/dist/cf-worker/do/observability.js.map +1 -0
  12. package/dist/cf-worker/do/observability.test.d.ts +2 -0
  13. package/dist/cf-worker/do/observability.test.d.ts.map +1 -0
  14. package/dist/cf-worker/do/observability.test.js +204 -0
  15. package/dist/cf-worker/do/observability.test.js.map +1 -0
  16. package/dist/cf-worker/do/pull.d.ts.map +1 -1
  17. package/dist/cf-worker/do/pull.js +3 -3
  18. package/dist/cf-worker/do/push.d.ts +2 -2
  19. package/dist/cf-worker/do/push.d.ts.map +1 -1
  20. package/dist/cf-worker/do/push.js +7 -6
  21. package/dist/cf-worker/do/push.js.map +1 -1
  22. package/dist/cf-worker/do/push.test.js +1 -1
  23. package/dist/cf-worker/do/sqlite.js +1 -1
  24. package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
  25. package/dist/cf-worker/do/sync-storage.js +3 -3
  26. package/dist/cf-worker/do/transport/do-rpc-server.d.ts +15 -0
  27. package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
  28. package/dist/cf-worker/do/transport/do-rpc-server.js +23 -7
  29. package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
  30. package/dist/cf-worker/do/transport/do-rpc-server.test.d.ts +2 -0
  31. package/dist/cf-worker/do/transport/do-rpc-server.test.d.ts.map +1 -0
  32. package/dist/cf-worker/do/transport/do-rpc-server.test.js +28 -0
  33. package/dist/cf-worker/do/transport/do-rpc-server.test.js.map +1 -0
  34. package/dist/cf-worker/do/transport/http-rpc-server.js +6 -6
  35. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +8 -1
  36. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
  37. package/dist/cf-worker/do/transport/ws-rpc-server.js +32 -24
  38. package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
  39. package/dist/cf-worker/mod.js +3 -3
  40. package/dist/cf-worker/shared.d.ts +17 -12
  41. package/dist/cf-worker/shared.d.ts.map +1 -1
  42. package/dist/cf-worker/shared.js +2 -2
  43. package/dist/cf-worker/shared.js.map +1 -1
  44. package/dist/cf-worker/worker.d.ts +3 -3
  45. package/dist/cf-worker/worker.d.ts.map +1 -1
  46. package/dist/cf-worker/worker.js +2 -2
  47. package/dist/cf-worker/worker.js.map +1 -1
  48. package/dist/client/mod.js +3 -3
  49. package/dist/client/transport/do-rpc-client.d.ts +6 -3
  50. package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
  51. package/dist/client/transport/do-rpc-client.js +65 -20
  52. package/dist/client/transport/do-rpc-client.js.map +1 -1
  53. package/dist/client/transport/do-rpc-client.test.d.ts +2 -0
  54. package/dist/client/transport/do-rpc-client.test.d.ts.map +1 -0
  55. package/dist/client/transport/do-rpc-client.test.js +268 -0
  56. package/dist/client/transport/do-rpc-client.test.js.map +1 -0
  57. package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
  58. package/dist/client/transport/http-rpc-client.js +20 -10
  59. package/dist/client/transport/http-rpc-client.js.map +1 -1
  60. package/dist/client/transport/rpc-client-error.d.ts +5 -0
  61. package/dist/client/transport/rpc-client-error.d.ts.map +1 -0
  62. package/dist/client/transport/rpc-client-error.js +8 -0
  63. package/dist/client/transport/rpc-client-error.js.map +1 -0
  64. package/dist/client/transport/rpc-client-error.test.d.ts +2 -0
  65. package/dist/client/transport/rpc-client-error.test.d.ts.map +1 -0
  66. package/dist/client/transport/rpc-client-error.test.js +33 -0
  67. package/dist/client/transport/rpc-client-error.test.js.map +1 -0
  68. package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
  69. package/dist/client/transport/ws-rpc-client.js +18 -12
  70. package/dist/client/transport/ws-rpc-client.js.map +1 -1
  71. package/dist/common/do-rpc-schema.d.ts +24 -11
  72. package/dist/common/do-rpc-schema.d.ts.map +1 -1
  73. package/dist/common/do-rpc-schema.js +10 -1
  74. package/dist/common/do-rpc-schema.js.map +1 -1
  75. package/dist/common/http-rpc-schema.d.ts +8 -8
  76. package/dist/common/http-rpc-schema.js +1 -1
  77. package/dist/common/mod.d.ts +1 -0
  78. package/dist/common/mod.d.ts.map +1 -1
  79. package/dist/common/mod.js +4 -3
  80. package/dist/common/mod.js.map +1 -1
  81. package/dist/common/sync-message-types.d.ts.map +1 -1
  82. package/dist/common/ws-rpc-schema.d.ts +1 -1
  83. package/dist/common/ws-rpc-schema.js +1 -1
  84. package/package.json +41 -41
  85. package/src/cf-worker/do/durable-object.ts +42 -45
  86. package/src/cf-worker/do/observability.test.ts +337 -0
  87. package/src/cf-worker/do/observability.ts +121 -0
  88. package/src/cf-worker/do/push.ts +3 -2
  89. package/src/cf-worker/do/transport/do-rpc-server.test.ts +32 -0
  90. package/src/cf-worker/do/transport/do-rpc-server.ts +23 -1
  91. package/src/cf-worker/do/transport/ws-rpc-server.ts +45 -21
  92. package/src/cf-worker/shared.ts +13 -6
  93. package/src/cf-worker/worker.ts +1 -1
  94. package/src/client/transport/do-rpc-client.test.ts +411 -0
  95. package/src/client/transport/do-rpc-client.ts +100 -18
  96. package/src/client/transport/http-rpc-client.ts +20 -9
  97. package/src/client/transport/rpc-client-error.test.ts +41 -0
  98. package/src/client/transport/rpc-client-error.ts +9 -0
  99. package/src/client/transport/ws-rpc-client.ts +15 -10
  100. package/src/common/do-rpc-schema.ts +10 -0
  101. package/src/common/mod.ts +1 -0
@@ -1,6 +1,6 @@
1
1
  import type { UnknownError } from '@livestore/common'
2
2
  import type { CfTypes } from '@livestore/common-cf'
3
- import type { Effect } from '@livestore/utils/effect'
3
+ import type { Effect, Layer } from '@livestore/utils/effect'
4
4
  import { Result, Schema } from '@livestore/utils/effect'
5
5
 
6
6
  import type { SearchParams } from '../common/mod.ts'
@@ -107,12 +107,19 @@ export type MakeDurableObjectClassOptions = {
107
107
  responseHeaders?: Record<string, string>
108
108
  }
109
109
 
110
- otel?: {
111
- baseUrl?: string
112
- serviceName?: string
113
- }
110
+ /** Optional telemetry. Omit to avoid creating an exporter or sending telemetry. */
111
+ otel?: SyncBackendOtelOptions
114
112
  }
115
113
 
114
+ export type SyncBackendOtelOptions =
115
+ /** Application-supplied tracer layer; LiveStore builds and finalizes its resources per sync operation. */
116
+ | Layer.Layer<never>
117
+ | {
118
+ /** Convenience OTLP/HTTP JSON exporter owned by LiveStore for each operation. */
119
+ baseUrl?: string
120
+ serviceName?: string
121
+ }
122
+
116
123
  export type StoreId = string
117
124
  export type DurableObjectId = string
118
125
 
@@ -146,7 +153,7 @@ const SearchParamsFromUrlSearchParams = Schema.fromURLSearchParams(SearchParamsS
146
153
  */
147
154
  export const matchSyncRequest = (request: CfTypes.Request): SearchParams | undefined => {
148
155
  const url = new URL(request.url)
149
- const paramsResult = Schema.decodeUnknownResult(SearchParamsFromUrlSearchParams)(url.searchParams)
156
+ const paramsResult = Schema.decodeResult(SearchParamsFromUrlSearchParams)(url.searchParams)
150
157
 
151
158
  if (Result.isFailure(paramsResult) === true) {
152
159
  return undefined
@@ -200,7 +200,7 @@ export const handleSyncRequest = <
200
200
  // Always decode with the supplied schema when present, even if payload is undefined.
201
201
  // This ensures required payloads are enforced by the schema.
202
202
  if (syncPayloadSchema !== undefined) {
203
- const decodedResult = Schema.decodeUnknownResult(syncPayloadSchema)(payload)
203
+ const decodedResult = Schema.decodeResult(syncPayloadSchema)(payload)
204
204
  if (Result.isFailure(decodedResult) === true) {
205
205
  const message = decodedResult.failure.toString()
206
206
  console.error('Invalid payload (decode failed)', message)
@@ -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
@@ -1,14 +1,19 @@
1
- import { SyncBackend, UnknownError } from '@livestore/common'
2
- import { type CfTypes, layerProtocolDurableObject } from '@livestore/common-cf'
1
+ import { IsOfflineError, SyncBackend, UnknownError } from '@livestore/common'
2
+ import { type CfTypes, DoRpcReadError, layerProtocolDurableObject } from '@livestore/common-cf'
3
3
  import { splitArrayBySize } from '@livestore/common/sync'
4
4
  import { shouldNeverHappen } from '@livestore/utils'
5
5
  import {
6
+ Duration,
6
7
  Effect,
7
8
  identity,
8
9
  Layer,
10
+ Match,
9
11
  Option,
12
+ Predicate,
10
13
  Queue,
14
+ Random,
11
15
  ReadonlyArray as EffectArray,
16
+ Ref,
12
17
  RpcClient,
13
18
  RpcSerialization,
14
19
  Schema,
@@ -28,8 +33,11 @@ type PushBatchItem = SyncMessage.PushRequest['batch'][number]
28
33
  export interface SyncBackendRpcStub extends CfTypes.DurableObjectStub, SyncBackendRpcInterface {}
29
34
 
30
35
  export interface DoRpcSyncOptions {
31
- /** Durable Object stub that implements the SyncDoRpc interface */
32
- syncBackendStub: SyncBackendRpcStub
36
+ /**
37
+ * Returns a stub for the sync backend Durable Object. Called for every RPC call, because Cloudflare leaves a stub
38
+ * broken after many exceptions, so a retry needs a fresh one.
39
+ */
40
+ getSyncBackendStub: () => SyncBackendRpcStub
33
41
  /**
34
42
  * State handle of the client DurableObject running this sync backend. Scopes live-pull routing to
35
43
  * this instance so it resets when the DO is reconstructed (see {@link handleSyncUpdateRpc}).
@@ -51,7 +59,7 @@ export interface DoRpcSyncOptions {
51
59
  */
52
60
  export const makeDoRpcSync =
53
61
  ({
54
- syncBackendStub,
62
+ getSyncBackendStub,
55
63
  durableObjectState,
56
64
  durableObjectContext,
57
65
  }: DoRpcSyncOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
@@ -60,14 +68,16 @@ export const makeDoRpcSync =
60
68
  const isConnected = yield* SubscriptionRef.make(true)
61
69
 
62
70
  const ProtocolLive = layerProtocolDurableObject({
63
- callRpc: (payload) => syncBackendStub.rpc(payload),
71
+ callRpc: (payload) => getSyncBackendStub().rpc(payload),
64
72
  callerContext: durableObjectContext,
65
- }).pipe(Layer.provide(RpcSerialization.layerJson))
73
+ })
66
74
 
67
75
  const context = yield* Layer.build(ProtocolLive)
68
76
 
69
77
  const rpcClient = yield* RpcClient.make(SyncDoRpc).pipe(Effect.provide(context))
70
78
 
79
+ const pullBackoff = yield* makePullBackoff
80
+
71
81
  // Nothing to do here
72
82
  const connect = Effect.void
73
83
 
@@ -84,6 +94,12 @@ export const makeDoRpcSync =
84
94
  storeId,
85
95
  rpcContext: options?.live === true ? { callerContext: durableObjectContext } : undefined,
86
96
  }).pipe(
97
+ // Catch-up must make progress. Live callbacks below may legitimately remain idle indefinitely.
98
+ Stream.timeoutOrElse({
99
+ duration: '60 seconds',
100
+ orElse: () =>
101
+ Stream.fail(new IsOfflineError({ cause: new Error('DO-RPC pull made no progress for 60 seconds') })),
102
+ }),
87
103
  options?.live === true
88
104
  ? Stream.concatWithLastElement((res) =>
89
105
  Effect.gen(function* () {
@@ -100,14 +116,28 @@ export const makeDoRpcSync =
100
116
 
101
117
  routing.set(requestId, queue)
102
118
 
119
+ // Graceful shutdown only (eviction runs no finalizers); matched on requestId so a late send can't drop a newer pull's row
120
+ yield* Effect.addFinalizer(() =>
121
+ rpcClient['SyncDoRpc.Unsubscribe']({
122
+ storeId,
123
+ durableObjectId: durableObjectContext.durableObjectId,
124
+ requestId,
125
+ }).pipe(Effect.timeout('5 seconds'), Effect.tapCauseLogPretty, Effect.ignore),
126
+ )
127
+
103
128
  return Stream.fromQueue(queue)
104
129
  }).pipe(Stream.unwrap),
105
130
  )
106
131
  : identity,
132
+ Stream.catchReason('RpcClientError', 'RpcClientDefect', (reason) =>
133
+ Stream.fromEffect(classifyTransportFailure(reason.cause)),
134
+ ),
135
+ Stream.tapError((error) => (error._tag === 'IsOfflineError' ? pullBackoff.wait : Effect.void)),
136
+ Stream.tap((res) => (res.pageInfo._tag === 'NoMore' ? pullBackoff.reset : Effect.void)),
107
137
  Stream.tap((res) => backendIdHelper.lazySet(res.backendId)),
108
138
  Stream.map((res) => Struct.omit(res, ['backendId'])),
109
139
  Stream.mapError((cause) =>
110
- cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
140
+ cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError' || cause._tag === 'IsOfflineError'
111
141
  ? cause
112
142
  : new UnknownError({ cause }),
113
143
  ),
@@ -139,8 +169,12 @@ export const makeDoRpcSync =
139
169
  yield* rpcClient['SyncDoRpc.Push']({ batch: batchChunk, storeId, backendId })
140
170
  }
141
171
  },
172
+ Effect.catchReason('RpcClientError', 'RpcClientDefect', (reason) => classifyTransportFailure(reason.cause)),
142
173
  Effect.mapError((cause) =>
143
- cause._tag === 'UnknownError' || cause._tag === 'ServerAheadError' || cause._tag === 'BackendIdMismatchError'
174
+ cause._tag === 'UnknownError' ||
175
+ cause._tag === 'ServerAheadError' ||
176
+ cause._tag === 'BackendIdMismatchError' ||
177
+ cause._tag === 'IsOfflineError'
144
178
  ? cause
145
179
  : new UnknownError({ cause }),
146
180
  ),
@@ -149,7 +183,11 @@ export const makeDoRpcSync =
149
183
  const ping: SyncBackend.SyncBackend<{ createdAt: string }>['ping'] = rpcClient['SyncDoRpc.Ping']({
150
184
  storeId,
151
185
  payload,
152
- }).pipe(UnknownError.mapToUnknownError, Effect.withSpan('rpc-sync-client:ping'))
186
+ }).pipe(
187
+ Effect.catchReason('RpcClientError', 'RpcClientDefect', (reason) => classifyTransportFailure(reason.cause)),
188
+ Effect.mapError((cause) => (cause._tag === 'IsOfflineError' ? cause : new UnknownError({ cause }))),
189
+ Effect.withSpan('rpc-sync-client:ping'),
190
+ )
153
191
 
154
192
  return SyncBackend.of({
155
193
  connect,
@@ -191,12 +229,12 @@ export const makeDoRpcSync =
191
229
  */
192
230
  export const handleSyncUpdateRpc = (ctx: CfTypes.DurableObjectState, payload: Uint8Array<ArrayBuffer>) =>
193
231
  Effect.gen(function* () {
194
- const parser = RpcSerialization.msgPack.makeUnsafe()
195
- const decodedMessage = parser.decode(payload)
196
- const [response] = Array.isArray(decodedMessage) === true ? decodedMessage.flat(1) : [decodedMessage]
232
+ const serialization = yield* RpcSerialization.RpcSerialization
233
+ const parser = serialization.makeUnsafe()
234
+ const [response] = parser.decode(payload)
197
235
  const decodedPayload = yield* Schema.decodeUnknownEffect(ResponseChunkEncoded)(response)
198
- const decoded = yield* Schema.decodeUnknownEffect(Schema.toCodecJson(SyncMessage.PullResponse))(
199
- decodedPayload.values[0],
236
+ const decoded = yield* Schema.decodeEffect(serialization.codecFor(Schema.NonEmptyArray(SyncMessage.PullResponse)))(
237
+ decodedPayload.values,
200
238
  )
201
239
 
202
240
  const pullStreamQueue = pullRoutingFor(ctx).get(decodedPayload.requestId)
@@ -206,13 +244,18 @@ export const handleSyncUpdateRpc = (ctx: CfTypes.DurableObjectState, payload: Ui
206
244
  yield* Effect.log(`No pull stream queue found for ${decodedPayload.requestId}`)
207
245
  } else {
208
246
  // Case: DO was still alive, so the existing `pull` will pick up the new events
209
- yield* Queue.offer(pullStreamQueue, decoded)
247
+ yield* Queue.offerAll(pullStreamQueue, decoded)
210
248
  }
211
- }).pipe(Effect.withSpan('rpc-sync-client:rpcCallback'), Effect.tapCauseLogPretty, Effect.runPromise)
249
+ }).pipe(
250
+ Effect.provide(RpcSerialization.layerSchemaBinary()),
251
+ Effect.withSpan('rpc-sync-client:rpcCallback'),
252
+ Effect.tapCauseLogPretty,
253
+ Effect.runPromise,
254
+ )
212
255
 
213
256
  const ResponseChunkEncoded = Schema.Struct({
214
257
  requestId: Schema.String,
215
- values: Schema.Array(Schema.Any),
258
+ values: Schema.Uint8Array,
216
259
  })
217
260
 
218
261
  type EffectRpcRequestId = string // 0, 1, 2, ...
@@ -235,3 +278,42 @@ const pullRoutingFor = (ctx: CfTypes.DurableObjectState): PullRouting => {
235
278
  pullRoutingByInstance.set(ctx, routing)
236
279
  return routing
237
280
  }
281
+
282
+ /**
283
+ * Calls require Cloudflare's retryable flag. Transferred streams can lose those flags, so unflagged read
284
+ * failures are also offline (application failures arrive as encoded RPC exits). Explicit overload and
285
+ * non-retryable remote errors stay terminal.
286
+ * https://developers.cloudflare.com/durable-objects/best-practices/error-handling/
287
+ */
288
+ const classifyTransportFailure = (failure: unknown) => {
289
+ const cause = failure instanceof DoRpcReadError ? failure.cause : failure
290
+ return Match.value({
291
+ retryable:
292
+ hasTrueFlag(cause, 'retryable') || (failure instanceof DoRpcReadError && hasTrueFlag(cause, 'remote') === false),
293
+ overloaded: hasTrueFlag(cause, 'overloaded'),
294
+ }).pipe(
295
+ Match.when({ overloaded: true }, () => Effect.die(cause)),
296
+ Match.when({ retryable: true }, () => Effect.fail(new IsOfflineError({ cause }))),
297
+ Match.orElse(() => Effect.die(cause)),
298
+ )
299
+ }
300
+
301
+ const hasTrueFlag = (cause: unknown, flag: 'retryable' | 'overloaded' | 'remote') =>
302
+ Predicate.hasProperty(cause, flag) === true && cause[flag] === true
303
+
304
+ /**
305
+ * This transport delays retryable failures before surfacing them, independently of the leader's connectivity gate.
306
+ * The attempt count spans pull calls and resets only when a pull completes its catch-up, so a pull that delivers a
307
+ * page and then fails does not reset it.
308
+ */
309
+ const makePullBackoff = Effect.gen(function* () {
310
+ const attempt = yield* Ref.make(0)
311
+ return {
312
+ wait: Effect.gen(function* () {
313
+ const n = yield* Ref.getAndUpdate(attempt, (n) => n + 1)
314
+ const jitter = yield* Random.nextBetween(0.8, 1.2)
315
+ yield* Effect.sleep(Duration.millis(Math.min(1_000 * 2 ** n, 30_000) * jitter))
316
+ }),
317
+ reset: Ref.set(attempt, 0),
318
+ }
319
+ })