@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
@@ -1,18 +1,24 @@
1
- import { SyncBackend, UnknownError } from '@livestore/common'
2
- import { splitChunkBySize } from '@livestore/common/sync'
3
- import { type CfTypes, layerProtocolDurableObject } from '@livestore/common-cf'
4
- import { omit, shouldNeverHappen } from '@livestore/utils'
1
+ import { IsOfflineError, SyncBackend, UnknownError } from '@livestore/common'
2
+ import { type CfTypes, DoRpcReadError, layerProtocolDurableObject } from '@livestore/common-cf'
3
+ import { splitArrayBySize } from '@livestore/common/sync'
4
+ import { shouldNeverHappen } from '@livestore/utils'
5
5
  import {
6
- Chunk,
6
+ Duration,
7
7
  Effect,
8
8
  identity,
9
9
  Layer,
10
- Mailbox,
10
+ Match,
11
11
  Option,
12
+ Predicate,
13
+ Queue,
14
+ Random,
15
+ ReadonlyArray as EffectArray,
16
+ Ref,
12
17
  RpcClient,
13
18
  RpcSerialization,
14
19
  Schema,
15
20
  Stream,
21
+ Struct,
16
22
  SubscriptionRef,
17
23
  } from '@livestore/utils/effect'
18
24
 
@@ -22,15 +28,21 @@ import { SyncDoRpc } from '../../common/do-rpc-schema.ts'
22
28
  import { SyncMessage } from '../../common/mod.ts'
23
29
  import type { SyncMetadata } from '../../common/sync-message-types.ts'
24
30
 
25
- export interface SyncBackendRpcStub extends CfTypes.DurableObjectStub, SyncBackendRpcInterface {}
31
+ type PushBatchItem = SyncMessage.PushRequest['batch'][number]
26
32
 
27
- // TODO we probably need better scoping for the requestIdMailboxMap (i.e. support multiple stores, ...)
28
- type EffectRpcRequestId = string // 0, 1, 2, ...
29
- const requestIdMailboxMap = new Map<EffectRpcRequestId, Mailbox.Mailbox<SyncMessage.PullResponse>>()
33
+ export interface SyncBackendRpcStub extends CfTypes.DurableObjectStub, SyncBackendRpcInterface {}
30
34
 
31
35
  export interface DoRpcSyncOptions {
32
- /** Durable Object stub that implements the SyncDoRpc interface */
33
- 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
41
+ /**
42
+ * State handle of the client DurableObject running this sync backend. Scopes live-pull routing to
43
+ * this instance so it resets when the DO is reconstructed (see {@link handleSyncUpdateRpc}).
44
+ */
45
+ durableObjectState: CfTypes.DurableObjectState
34
46
  /** Information about this DurableObject instance so the Sync DO instance can call back to this instance */
35
47
  durableObjectContext: {
36
48
  /** See `wrangler.toml` for the binding name */
@@ -46,27 +58,33 @@ export interface DoRpcSyncOptions {
46
58
  * Used internally by `@livestore/adapter-cf` to connect to the sync backend.
47
59
  */
48
60
  export const makeDoRpcSync =
49
- ({ syncBackendStub, durableObjectContext }: DoRpcSyncOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
61
+ ({
62
+ getSyncBackendStub,
63
+ durableObjectState,
64
+ durableObjectContext,
65
+ }: DoRpcSyncOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
50
66
  ({ storeId, payload }) =>
51
67
  Effect.gen(function* () {
52
68
  const isConnected = yield* SubscriptionRef.make(true)
53
69
 
54
70
  const ProtocolLive = layerProtocolDurableObject({
55
- callRpc: (payload) => syncBackendStub.rpc(payload),
71
+ callRpc: (payload) => getSyncBackendStub().rpc(payload),
56
72
  callerContext: durableObjectContext,
57
- }).pipe(Layer.provide(RpcSerialization.layerJson))
73
+ })
58
74
 
59
75
  const context = yield* Layer.build(ProtocolLive)
60
76
 
61
77
  const rpcClient = yield* RpcClient.make(SyncDoRpc).pipe(Effect.provide(context))
62
78
 
79
+ const pullBackoff = yield* makePullBackoff
80
+
63
81
  // Nothing to do here
64
82
  const connect = Effect.void
65
83
 
66
84
  const backendIdHelper = yield* SyncBackend.makeBackendIdHelper
67
85
 
68
86
  const pull: SyncBackend.SyncBackend<SyncMetadata>['pull'] = (cursor, options) =>
69
- rpcClient.SyncDoRpc.Pull({
87
+ rpcClient['SyncDoRpc.Pull']({
70
88
  cursor: cursor.pipe(
71
89
  Option.map((a) => ({
72
90
  eventSequenceNumber: a.eventSequenceNumber,
@@ -76,26 +94,50 @@ export const makeDoRpcSync =
76
94
  storeId,
77
95
  rpcContext: options?.live === true ? { callerContext: durableObjectContext } : undefined,
78
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
+ }),
79
103
  options?.live === true
80
104
  ? Stream.concatWithLastElement((res) =>
81
105
  Effect.gen(function* () {
82
106
  if (res._tag === 'None')
83
107
  return shouldNeverHappen('There should at least be a no-more page info response')
84
108
 
85
- const mailbox = yield* Mailbox.make<SyncMessage.PullResponse>().pipe(
86
- Effect.acquireRelease((mailbox) => mailbox.shutdown),
109
+ const requestId = res.value.rpcRequestId
110
+ const routing = pullRoutingFor(durableObjectState)
111
+
112
+ const queue = yield* Effect.acquireRelease(Queue.unbounded<SyncMessage.PullResponse>(), (queue) =>
113
+ // Drop the routing entry on release so it can't outlive its (now shut-down) queue
114
+ Effect.sync(() => routing.delete(requestId)).pipe(Effect.andThen(Queue.shutdown(queue))),
87
115
  )
88
116
 
89
- requestIdMailboxMap.set(res.value.rpcRequestId, mailbox)
117
+ routing.set(requestId, queue)
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
+ )
90
127
 
91
- return Mailbox.toStream(mailbox)
92
- }).pipe(Stream.unwrapScoped),
128
+ return Stream.fromQueue(queue)
129
+ }).pipe(Stream.unwrap),
93
130
  )
94
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)),
95
137
  Stream.tap((res) => backendIdHelper.lazySet(res.backendId)),
96
- Stream.map((res) => omit(res, ['backendId'])),
138
+ Stream.map((res) => Struct.omit(res, ['backendId'])),
97
139
  Stream.mapError((cause) =>
98
- cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
140
+ cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError' || cause._tag === 'IsOfflineError'
99
141
  ? cause
100
142
  : new UnknownError({ cause }),
101
143
  ),
@@ -109,35 +151,43 @@ export const makeDoRpcSync =
109
151
  }
110
152
 
111
153
  const backendId = backendIdHelper.get()
112
- const batchChunks = yield* Chunk.fromIterable(batch).pipe(
113
- splitChunkBySize({
114
- maxItems: MAX_PUSH_EVENTS_PER_REQUEST,
115
- maxBytes: MAX_DO_RPC_REQUEST_BYTES,
116
- encode: (items) => ({
117
- batch: items,
118
- storeId,
119
- backendId,
120
- }),
154
+ if (EffectArray.isReadonlyArrayNonEmpty(batch) === false) {
155
+ return
156
+ }
157
+
158
+ const batchChunks = yield* splitArrayBySize({
159
+ maxItems: MAX_PUSH_EVENTS_PER_REQUEST,
160
+ maxBytes: MAX_DO_RPC_REQUEST_BYTES,
161
+ encode: (items: ReadonlyArray<PushBatchItem>) => ({
162
+ batch: items,
163
+ storeId,
164
+ backendId,
121
165
  }),
122
- Effect.mapError((cause) => new UnknownError({ cause })),
123
- )
166
+ })(batch).pipe(Effect.mapError((cause) => new UnknownError({ cause })))
124
167
 
125
- for (const chunk of Chunk.toReadonlyArray(batchChunks)) {
126
- const chunkArray = Chunk.toReadonlyArray(chunk)
127
- yield* rpcClient.SyncDoRpc.Push({ batch: chunkArray, storeId, backendId })
168
+ for (const batchChunk of batchChunks) {
169
+ yield* rpcClient['SyncDoRpc.Push']({ batch: batchChunk, storeId, backendId })
128
170
  }
129
171
  },
172
+ Effect.catchReason('RpcClientError', 'RpcClientDefect', (reason) => classifyTransportFailure(reason.cause)),
130
173
  Effect.mapError((cause) =>
131
- 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'
132
178
  ? cause
133
179
  : new UnknownError({ cause }),
134
180
  ),
135
181
  )
136
182
 
137
- const ping: SyncBackend.SyncBackend<{ createdAt: string }>['ping'] = rpcClient.SyncDoRpc.Ping({
183
+ const ping: SyncBackend.SyncBackend<{ createdAt: string }>['ping'] = rpcClient['SyncDoRpc.Ping']({
138
184
  storeId,
139
185
  payload,
140
- }).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
+ )
141
191
 
142
192
  return SyncBackend.of({
143
193
  connect,
@@ -159,6 +209,9 @@ export const makeDoRpcSync =
159
209
  }).pipe(Effect.withSpan('rpc-sync-client:makeDoRpcSync'))
160
210
 
161
211
  /**
212
+ * Routes an update from the sync backend into this client's live pull.
213
+ *
214
+ * Only `ctx` and `payload` go here; `storeId` is for reloading your store on a rebuilt DO (see example).
162
215
  *
163
216
  * ```ts
164
217
  * import { DurableObject } from 'cloudflare:workers'
@@ -167,29 +220,100 @@ export const makeDoRpcSync =
167
220
  * export class MyDurableObject extends DurableObject implements ClientDoWithRpcCallback {
168
221
  * // ...
169
222
  *
170
- * async syncUpdateRpc(payload: RpcMessage.ResponseChunkEncoded) {
171
- * return handleSyncUpdateRpc(payload)
223
+ * async syncUpdateRpc(payload: Uint8Array<ArrayBuffer>, storeId: string) {
224
+ * await this.getStore(storeId)
225
+ * return handleSyncUpdateRpc(this.ctx, payload)
172
226
  * }
173
227
  * }
174
228
  * ```
175
229
  */
176
- export const handleSyncUpdateRpc = (payload: unknown) =>
230
+ export const handleSyncUpdateRpc = (ctx: CfTypes.DurableObjectState, payload: Uint8Array<ArrayBuffer>) =>
177
231
  Effect.gen(function* () {
178
- const decodedPayload = yield* Schema.decodeUnknown(ResponseChunkEncoded)(payload)
179
- const decoded = yield* Schema.decodeUnknown(SyncMessage.PullResponse)(decodedPayload.values[0])
232
+ const serialization = yield* RpcSerialization.RpcSerialization
233
+ const parser = serialization.makeUnsafe()
234
+ const [response] = parser.decode(payload)
235
+ const decodedPayload = yield* Schema.decodeUnknownEffect(ResponseChunkEncoded)(response)
236
+ const decoded = yield* Schema.decodeEffect(serialization.codecFor(Schema.NonEmptyArray(SyncMessage.PullResponse)))(
237
+ decodedPayload.values,
238
+ )
180
239
 
181
- const pullStreamMailbox = requestIdMailboxMap.get(decodedPayload.requestId)
240
+ const pullStreamQueue = pullRoutingFor(ctx).get(decodedPayload.requestId)
182
241
 
183
- if (pullStreamMailbox === undefined) {
242
+ if (pullStreamQueue === undefined) {
184
243
  // Case: DO was hibernated, so we need to manually update the store
185
- yield* Effect.log(`No mailbox found for ${decodedPayload.requestId}`)
244
+ yield* Effect.log(`No pull stream queue found for ${decodedPayload.requestId}`)
186
245
  } else {
187
246
  // Case: DO was still alive, so the existing `pull` will pick up the new events
188
- yield* pullStreamMailbox.offer(decoded)
247
+ yield* Queue.offerAll(pullStreamQueue, decoded)
189
248
  }
190
- }).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
+ )
191
255
 
192
256
  const ResponseChunkEncoded = Schema.Struct({
193
257
  requestId: Schema.String,
194
- values: Schema.Array(Schema.Any),
258
+ values: Schema.Uint8Array,
259
+ })
260
+
261
+ type EffectRpcRequestId = string // 0, 1, 2, ...
262
+ type PullRouting = Map<EffectRpcRequestId, Queue.Queue<SyncMessage.PullResponse>>
263
+
264
+ /**
265
+ * Per-instance map from a live pull's request id to its response queue, keyed off the client DO's
266
+ * `DurableObjectState` (not a module global) for two independent reasons:
267
+ * - Per-instance: a reconstructed DO starts empty, so a reverse-RPC whose store is gone hits the
268
+ * `handleSyncUpdateRpc` drop branch (the recovery hook) instead of a stale queue that outlived it.
269
+ * - Keyed by request id: a late chunk from a superseded pull generation finds no entry and is
270
+ * dropped here — never reaching `SyncState.merge`, which dies on an event at/below the upstream head.
271
+ */
272
+ const pullRoutingByInstance = new WeakMap<CfTypes.DurableObjectState, PullRouting>()
273
+
274
+ const pullRoutingFor = (ctx: CfTypes.DurableObjectState): PullRouting => {
275
+ const existing = pullRoutingByInstance.get(ctx)
276
+ if (existing !== undefined) return existing
277
+ const routing: PullRouting = new Map()
278
+ pullRoutingByInstance.set(ctx, routing)
279
+ return routing
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
+ }
195
319
  })
@@ -1,9 +1,7 @@
1
1
  import { SyncBackend, UnknownError } from '@livestore/common'
2
- import type { EventSequenceNumber } from '@livestore/common/schema'
3
- import { splitChunkBySize } from '@livestore/common/sync'
4
- import { omit } from '@livestore/utils'
2
+ import type { EventSequenceNumber, LiveStoreEvent } from '@livestore/common/schema'
3
+ import { splitArrayBySize } from '@livestore/common/sync'
5
4
  import {
6
- Chunk,
7
5
  type Duration,
8
6
  Effect,
9
7
  HttpClient,
@@ -11,11 +9,14 @@ import {
11
9
  identity,
12
10
  Layer,
13
11
  Option,
12
+ ReadonlyArray as EffectArray,
14
13
  RpcClient,
15
14
  RpcSerialization,
16
15
  Schedule,
17
16
  Schema,
17
+ Semaphore,
18
18
  Stream,
19
+ Struct,
19
20
  SubscriptionRef,
20
21
  UrlParams,
21
22
  } from '@livestore/utils/effect'
@@ -24,6 +25,9 @@ import { MAX_HTTP_REQUEST_BYTES, MAX_PUSH_EVENTS_PER_REQUEST } from '../../commo
24
25
  import { SyncHttpRpc } from '../../common/http-rpc-schema.ts'
25
26
  import { SearchParamsSchema } from '../../common/mod.ts'
26
27
  import type { SyncMetadata } from '../../common/sync-message-types.ts'
28
+ import { mapRpcClientError } from './rpc-client-error.ts'
29
+
30
+ type PushBatchItem = LiveStoreEvent.Global.Encoded
27
31
 
28
32
  export interface HttpSyncOptions {
29
33
  /**
@@ -41,7 +45,7 @@ export interface HttpSyncOptions {
41
45
  * How often to poll for new events
42
46
  * @default 5 seconds
43
47
  */
44
- pollInterval?: Duration.DurationInput
48
+ pollInterval?: Duration.Input
45
49
  }
46
50
  ping?: {
47
51
  /**
@@ -52,12 +56,12 @@ export interface HttpSyncOptions {
52
56
  * How long to wait for a ping response before timing out
53
57
  * @default 10 seconds
54
58
  */
55
- requestTimeout?: Duration.DurationInput
59
+ requestTimeout?: Duration.Input
56
60
  /**
57
61
  * How often to send ping requests
58
62
  * @default 10 seconds
59
63
  */
60
- requestInterval?: Duration.DurationInput
64
+ requestInterval?: Duration.Input
61
65
  }
62
66
  }
63
67
 
@@ -73,7 +77,7 @@ export const makeHttpSync =
73
77
 
74
78
  const livePullInterval = options.livePull?.pollInterval ?? 5_000
75
79
 
76
- const urlParamsData = yield* Schema.encode(SearchParamsSchema)({
80
+ const urlParamsData = yield* Schema.encodeEffect(SearchParamsSchema)({
77
81
  storeId,
78
82
  payload,
79
83
  transport: 'http',
@@ -83,9 +87,10 @@ export const makeHttpSync =
83
87
 
84
88
  // Setup HTTP RPC Protocol
85
89
  const HttpProtocolLive = RpcClient.layerProtocolHttp({
86
- url: `${options.url}?${UrlParams.toString(urlParams)}`,
90
+ url: options.url,
87
91
  transformClient: HttpClient.mapRequest((request) =>
88
92
  request.pipe(
93
+ HttpClientRequest.appendUrlParams(urlParams),
89
94
  HttpClientRequest.setHeaders({
90
95
  ...options.headers,
91
96
  // Used in CF Worker to identify the store (additionally to storeId embedded in the RPC requests)
@@ -100,13 +105,17 @@ export const makeHttpSync =
100
105
  const pingTimeout = options.ping?.requestTimeout ?? 10_000
101
106
 
102
107
  const ping: SyncBackend.SyncBackend<SyncMetadata>['ping'] = Effect.gen(function* () {
103
- yield* rpcClient.SyncHttpRpc.Ping({ storeId, payload })
108
+ yield* rpcClient['SyncHttpRpc.Ping']({ storeId, payload })
104
109
 
105
110
  yield* SubscriptionRef.set(isConnected, true)
106
111
  }).pipe(
107
- UnknownError.mapToUnknownError,
108
- Effect.timeout(pingTimeout),
109
- Effect.catchTag('TimeoutException', () => SubscriptionRef.set(isConnected, false)),
112
+ Effect.mapError((cause) =>
113
+ cause._tag === 'RpcClientError' ? mapRpcClientError(cause) : new UnknownError({ cause }),
114
+ ),
115
+ Effect.timeoutOrElse({
116
+ duration: pingTimeout,
117
+ orElse: () => SubscriptionRef.set(isConnected, false),
118
+ }),
110
119
  )
111
120
 
112
121
  const pingInterval = options.ping?.requestInterval ?? 10_000
@@ -130,7 +139,7 @@ export const makeHttpSync =
130
139
  )
131
140
 
132
141
  const pull: SyncBackend.SyncBackend<SyncMetadata>['pull'] = (cursor, options) =>
133
- rpcClient.SyncHttpRpc.Pull({
142
+ rpcClient['SyncHttpRpc.Pull']({
134
143
  storeId,
135
144
  payload,
136
145
  cursor: mapCursor(cursor),
@@ -139,43 +148,51 @@ export const makeHttpSync =
139
148
  ? // Phase 2: Simulate `live` pull by polling for new events
140
149
  Stream.concatWithLastElement((lastElement) => {
141
150
  const initialPhase2Cursor = lastElement.pipe(
142
- Option.flatMap((_) => Option.fromNullable(_.batch.at(-1)?.eventEncoded.seqNum)),
151
+ Option.flatMap((_) => Option.fromNullishOr(_.batch.at(-1)?.eventEncoded.seqNum)),
143
152
  Option.map((eventSequenceNumber) => ({ eventSequenceNumber })),
144
153
  Option.orElse(() => cursor),
145
154
  mapCursor,
146
155
  )
147
156
 
148
- return Stream.unfoldChunkEffect(initialPhase2Cursor, (currentCursor) =>
157
+ return Stream.paginate(initialPhase2Cursor, (currentCursor) =>
149
158
  Effect.gen(function* () {
150
159
  yield* Effect.sleep(livePullInterval)
151
160
 
152
- const items = yield* rpcClient.SyncHttpRpc.Pull({ storeId, payload, cursor: currentCursor }).pipe(
153
- Stream.runCollect,
154
- )
161
+ const items = yield* rpcClient['SyncHttpRpc.Pull']({
162
+ storeId,
163
+ payload,
164
+ cursor: currentCursor,
165
+ }).pipe(Stream.runCollect)
155
166
 
156
- const nextCursor = Chunk.last(items).pipe(
157
- Option.flatMap((item) => Option.fromNullable(item.batch.at(-1)?.eventEncoded.seqNum)),
167
+ const nextCursor = EffectArray.last(items).pipe(
168
+ Option.flatMap((item) => Option.fromNullishOr(item.batch.at(-1)?.eventEncoded.seqNum)),
158
169
  Option.map((eventSequenceNumber) => ({ eventSequenceNumber })),
159
170
  Option.orElse(() => currentCursor),
160
171
  mapCursor,
161
172
  )
162
173
 
163
- return Option.some([items, nextCursor])
174
+ const page: readonly [typeof items, Option.Option<typeof currentCursor>] = [
175
+ items,
176
+ Option.some(nextCursor),
177
+ ]
178
+ return page
164
179
  }),
165
180
  )
166
181
  })
167
182
  : identity,
168
183
  Stream.tap((res) => backendIdHelper.lazySet(res.backendId)),
169
- Stream.map((res) => omit(res, ['backendId'])),
184
+ Stream.map((res) => Struct.omit(res, ['backendId'])),
170
185
  Stream.mapError((cause) =>
171
- cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
172
- ? cause
173
- : new UnknownError({ cause }),
186
+ cause._tag === 'RpcClientError'
187
+ ? mapRpcClientError(cause)
188
+ : cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
189
+ ? cause
190
+ : new UnknownError({ cause }),
174
191
  ),
175
192
  Stream.withSpan('http-sync-client:pull'),
176
193
  )
177
194
 
178
- const pushSemaphore = yield* Effect.makeSemaphore(1)
195
+ const pushSemaphore = yield* Semaphore.make(1)
179
196
 
180
197
  const push: SyncBackend.SyncBackend<SyncMetadata>['push'] = Effect.fn('http-sync-client:push')(
181
198
  function* (batch) {
@@ -184,30 +201,34 @@ export const makeHttpSync =
184
201
  }
185
202
 
186
203
  const backendId = backendIdHelper.get()
187
- const batchChunks = yield* Chunk.fromIterable(batch).pipe(
188
- splitChunkBySize({
189
- maxItems: MAX_PUSH_EVENTS_PER_REQUEST,
190
- maxBytes: MAX_HTTP_REQUEST_BYTES,
191
- encode: (items) => ({
192
- batch: items,
193
- storeId,
194
- payload,
195
- backendId,
196
- }),
204
+ if (EffectArray.isReadonlyArrayNonEmpty(batch) === false) {
205
+ return
206
+ }
207
+
208
+ const batchChunks = yield* splitArrayBySize({
209
+ maxItems: MAX_PUSH_EVENTS_PER_REQUEST,
210
+ maxBytes: MAX_HTTP_REQUEST_BYTES,
211
+ encode: (items: ReadonlyArray<PushBatchItem>) => ({
212
+ batch: items,
213
+ storeId,
214
+ payload,
215
+ backendId,
197
216
  }),
198
- Effect.mapError((cause) => new UnknownError({ cause })),
199
- )
217
+ })(batch).pipe(Effect.mapError((cause) => new UnknownError({ cause })))
200
218
 
201
- for (const chunk of Chunk.toReadonlyArray(batchChunks)) {
202
- const chunkArray = Chunk.toReadonlyArray(chunk)
203
- yield* rpcClient.SyncHttpRpc.Push({ storeId, payload, batch: chunkArray, backendId })
219
+ for (const batchChunk of batchChunks) {
220
+ yield* rpcClient['SyncHttpRpc.Push']({ storeId, payload, batch: batchChunk, backendId })
204
221
  }
205
222
  },
206
223
  pushSemaphore.withPermits(1),
207
224
  Effect.mapError((cause) =>
208
- cause._tag === 'UnknownError' || cause._tag === 'ServerAheadError' || cause._tag === 'BackendIdMismatchError'
209
- ? cause
210
- : new UnknownError({ cause }),
225
+ cause._tag === 'RpcClientError'
226
+ ? mapRpcClientError(cause)
227
+ : cause._tag === 'UnknownError' ||
228
+ cause._tag === 'ServerAheadError' ||
229
+ cause._tag === 'BackendIdMismatchError'
230
+ ? cause
231
+ : new UnknownError({ cause }),
211
232
  ),
212
233
  )
213
234
 
@@ -0,0 +1,41 @@
1
+ import { expect, it } from 'vitest'
2
+
3
+ import { IsOfflineError, UnknownError } from '@livestore/common'
4
+ import { HttpClientError, RpcClientError, Socket } from '@livestore/utils/effect'
5
+
6
+ import { mapRpcClientError } from './rpc-client-error.ts'
7
+
8
+ const socketReasons = [
9
+ new Socket.SocketCloseError({ code: 1012, closeReason: 'Restart' }),
10
+ new Socket.SocketReadError({ cause: new Error('Disconnected') }),
11
+ new Socket.SocketWriteError({ cause: new Error('Disconnected') }),
12
+ new Socket.SocketOpenError({ kind: 'Timeout', cause: new Error('Unavailable') }),
13
+ new Socket.SocketUpgradeError({ cause: new Error('Upgrade failed') }),
14
+ ]
15
+
16
+ it.each(socketReasons)('maps $\u005ftag to offline', (reason) => {
17
+ const result = mapRpcClientError(new RpcClientError.RpcClientError({ reason }))
18
+ expect(result).toBeInstanceOf(IsOfflineError)
19
+ expect(result.cause).toBe(reason)
20
+ })
21
+
22
+ it('maps an HTTP transport failure to offline', () => {
23
+ const reason = new HttpClientError.HttpClientErrorSchema({ kind: 'TransportError' })
24
+ const result = mapRpcClientError(new RpcClientError.RpcClientError({ reason }))
25
+ expect(result).toBeInstanceOf(IsOfflineError)
26
+ expect(result.cause).toBe(reason)
27
+ })
28
+
29
+ const protocolReasons = [
30
+ new RpcClientError.RpcClientDefect({ message: 'Malformed RPC response', cause: new Error('Invalid message') }),
31
+ ...(['EncodeError', 'DecodeError', 'InvalidUrlError', 'StatusCodeError', 'EmptyBodyError'] as const).map(
32
+ (kind) => new HttpClientError.HttpClientErrorSchema({ kind }),
33
+ ),
34
+ ]
35
+
36
+ it.each(protocolReasons)('keeps protocol failure $\u005ftag terminal', (reason) => {
37
+ const error = new RpcClientError.RpcClientError({ reason })
38
+ const result = mapRpcClientError(error)
39
+ expect(result).toBeInstanceOf(UnknownError)
40
+ expect(result.cause).toBe(error)
41
+ })
@@ -0,0 +1,9 @@
1
+ import { IsOfflineError, UnknownError } from '@livestore/common'
2
+ import { type RpcClientError, Schema, Socket } from '@livestore/utils/effect'
3
+
4
+ /** Only transport loss is retryable. RPC defects and HTTP protocol failures stay terminal. */
5
+ export const mapRpcClientError = (error: RpcClientError.RpcClientError): IsOfflineError | UnknownError =>
6
+ Schema.is(Socket.SocketErrorReason)(error.reason) === true ||
7
+ (error.reason._tag === 'HttpError' && error.reason.kind === 'TransportError')
8
+ ? new IsOfflineError({ cause: error.reason })
9
+ : new UnknownError({ cause: error })