@livestore/sync-cf 0.4.0-dev.9 → 0.5.0-dev.0

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 (113) hide show
  1. package/README.md +7 -8
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cf-worker/do/durable-object.d.ts +1 -1
  4. package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
  5. package/dist/cf-worker/do/durable-object.js +36 -18
  6. package/dist/cf-worker/do/durable-object.js.map +1 -1
  7. package/dist/cf-worker/do/layer.d.ts +24 -20
  8. package/dist/cf-worker/do/layer.d.ts.map +1 -1
  9. package/dist/cf-worker/do/layer.js +57 -24
  10. package/dist/cf-worker/do/layer.js.map +1 -1
  11. package/dist/cf-worker/do/pull.d.ts +8 -3
  12. package/dist/cf-worker/do/pull.d.ts.map +1 -1
  13. package/dist/cf-worker/do/pull.js +26 -21
  14. package/dist/cf-worker/do/pull.js.map +1 -1
  15. package/dist/cf-worker/do/push.d.ts +20 -6
  16. package/dist/cf-worker/do/push.d.ts.map +1 -1
  17. package/dist/cf-worker/do/push.js +84 -56
  18. package/dist/cf-worker/do/push.js.map +1 -1
  19. package/dist/cf-worker/do/push.test.d.ts +2 -0
  20. package/dist/cf-worker/do/push.test.d.ts.map +1 -0
  21. package/dist/cf-worker/do/push.test.js +30 -0
  22. package/dist/cf-worker/do/push.test.js.map +1 -0
  23. package/dist/cf-worker/do/sqlite.d.ts +49 -52
  24. package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
  25. package/dist/cf-worker/do/sqlite.js +14 -5
  26. package/dist/cf-worker/do/sqlite.js.map +1 -1
  27. package/dist/cf-worker/do/sync-storage.d.ts +15 -9
  28. package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
  29. package/dist/cf-worker/do/sync-storage.js +111 -25
  30. package/dist/cf-worker/do/sync-storage.js.map +1 -1
  31. package/dist/cf-worker/do/transport/do-rpc-server.d.ts +2 -2
  32. package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
  33. package/dist/cf-worker/do/transport/do-rpc-server.js +31 -23
  34. package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
  35. package/dist/cf-worker/do/transport/http-rpc-server.d.ts +5 -3
  36. package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
  37. package/dist/cf-worker/do/transport/http-rpc-server.js +28 -19
  38. package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
  39. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +3 -2
  40. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
  41. package/dist/cf-worker/do/transport/ws-rpc-server.js +32 -10
  42. package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
  43. package/dist/cf-worker/shared.d.ts +124 -34
  44. package/dist/cf-worker/shared.d.ts.map +1 -1
  45. package/dist/cf-worker/shared.js +52 -17
  46. package/dist/cf-worker/shared.js.map +1 -1
  47. package/dist/cf-worker/worker.d.ts +47 -39
  48. package/dist/cf-worker/worker.d.ts.map +1 -1
  49. package/dist/cf-worker/worker.js +54 -35
  50. package/dist/cf-worker/worker.js.map +1 -1
  51. package/dist/client/transport/do-rpc-client.d.ts +13 -4
  52. package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
  53. package/dist/client/transport/do-rpc-client.js +74 -28
  54. package/dist/client/transport/do-rpc-client.js.map +1 -1
  55. package/dist/client/transport/http-rpc-client.d.ts +3 -3
  56. package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
  57. package/dist/client/transport/http-rpc-client.js +51 -22
  58. package/dist/client/transport/http-rpc-client.js.map +1 -1
  59. package/dist/client/transport/ws-rpc-client.d.ts +4 -3
  60. package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
  61. package/dist/client/transport/ws-rpc-client.js +40 -22
  62. package/dist/client/transport/ws-rpc-client.js.map +1 -1
  63. package/dist/common/constants.d.ts +7 -0
  64. package/dist/common/constants.d.ts.map +1 -0
  65. package/dist/common/constants.js +17 -0
  66. package/dist/common/constants.js.map +1 -0
  67. package/dist/common/do-rpc-schema.d.ts +42 -43
  68. package/dist/common/do-rpc-schema.d.ts.map +1 -1
  69. package/dist/common/do-rpc-schema.js +5 -5
  70. package/dist/common/do-rpc-schema.js.map +1 -1
  71. package/dist/common/http-rpc-schema.d.ts +37 -38
  72. package/dist/common/http-rpc-schema.d.ts.map +1 -1
  73. package/dist/common/http-rpc-schema.js +7 -7
  74. package/dist/common/http-rpc-schema.js.map +1 -1
  75. package/dist/common/mod.d.ts +5 -3
  76. package/dist/common/mod.d.ts.map +1 -1
  77. package/dist/common/mod.js +4 -2
  78. package/dist/common/mod.js.map +1 -1
  79. package/dist/common/sync-message-types.d.ts +113 -130
  80. package/dist/common/sync-message-types.d.ts.map +1 -1
  81. package/dist/common/sync-message-types.js +29 -17
  82. package/dist/common/sync-message-types.js.map +1 -1
  83. package/dist/common/ws-rpc-schema.d.ts +34 -35
  84. package/dist/common/ws-rpc-schema.d.ts.map +1 -1
  85. package/dist/common/ws-rpc-schema.js +5 -5
  86. package/dist/common/ws-rpc-schema.js.map +1 -1
  87. package/package.json +57 -15
  88. package/src/cf-worker/do/durable-object.ts +59 -41
  89. package/src/cf-worker/do/layer.ts +153 -88
  90. package/src/cf-worker/do/pull.ts +48 -31
  91. package/src/cf-worker/do/push.test.ts +40 -0
  92. package/src/cf-worker/do/push.ts +163 -125
  93. package/src/cf-worker/do/sqlite.ts +15 -5
  94. package/src/cf-worker/do/sync-storage.ts +171 -38
  95. package/src/cf-worker/do/transport/do-rpc-server.ts +40 -40
  96. package/src/cf-worker/do/transport/http-rpc-server.ts +37 -20
  97. package/src/cf-worker/do/transport/ws-rpc-server.ts +44 -16
  98. package/src/cf-worker/shared.ts +152 -36
  99. package/src/cf-worker/worker.ts +110 -58
  100. package/src/client/transport/do-rpc-client.ts +106 -40
  101. package/src/client/transport/http-rpc-client.ts +74 -38
  102. package/src/client/transport/ws-rpc-client.ts +55 -29
  103. package/src/common/constants.ts +18 -0
  104. package/src/common/do-rpc-schema.ts +6 -5
  105. package/src/common/http-rpc-schema.ts +8 -7
  106. package/src/common/mod.ts +4 -3
  107. package/src/common/sync-message-types.ts +19 -19
  108. package/src/common/ws-rpc-schema.ts +6 -5
  109. package/dist/cf-worker/do/ws-chunking.d.ts +0 -22
  110. package/dist/cf-worker/do/ws-chunking.d.ts.map +0 -1
  111. package/dist/cf-worker/do/ws-chunking.js +0 -49
  112. package/dist/cf-worker/do/ws-chunking.js.map +0 -1
  113. package/src/cf-worker/do/ws-chunking.ts +0 -76
@@ -1,32 +1,40 @@
1
- import { InvalidPullError, InvalidPushError, SyncBackend, UnexpectedError } from '@livestore/common'
1
+ import { SyncBackend, UnknownError } from '@livestore/common'
2
2
  import { type CfTypes, layerProtocolDurableObject } from '@livestore/common-cf'
3
- import { omit, shouldNeverHappen } from '@livestore/utils'
3
+ import { splitArrayBySize } from '@livestore/common/sync'
4
+ import { shouldNeverHappen } from '@livestore/utils'
4
5
  import {
5
6
  Effect,
6
7
  identity,
7
8
  Layer,
8
- Mailbox,
9
9
  Option,
10
+ Queue,
11
+ ReadonlyArray as EffectArray,
10
12
  RpcClient,
11
13
  RpcSerialization,
12
14
  Schema,
13
15
  Stream,
16
+ Struct,
14
17
  SubscriptionRef,
15
18
  } from '@livestore/utils/effect'
19
+
16
20
  import type { SyncBackendRpcInterface } from '../../cf-worker/shared.ts'
21
+ import { MAX_DO_RPC_REQUEST_BYTES, MAX_PUSH_EVENTS_PER_REQUEST } from '../../common/constants.ts'
17
22
  import { SyncDoRpc } from '../../common/do-rpc-schema.ts'
18
23
  import { SyncMessage } from '../../common/mod.ts'
19
24
  import type { SyncMetadata } from '../../common/sync-message-types.ts'
20
25
 
21
- export interface SyncBackendRpcStub extends CfTypes.DurableObjectStub, SyncBackendRpcInterface {}
26
+ type PushBatchItem = SyncMessage.PushRequest['batch'][number]
22
27
 
23
- // TODO we probably need better scoping for the requestIdMailboxMap (i.e. support multiple stores, ...)
24
- type EffectRpcRequestId = string // 0, 1, 2, ...
25
- const requestIdMailboxMap = new Map<EffectRpcRequestId, Mailbox.Mailbox<SyncMessage.PullResponse>>()
28
+ export interface SyncBackendRpcStub extends CfTypes.DurableObjectStub, SyncBackendRpcInterface {}
26
29
 
27
30
  export interface DoRpcSyncOptions {
28
31
  /** Durable Object stub that implements the SyncDoRpc interface */
29
32
  syncBackendStub: SyncBackendRpcStub
33
+ /**
34
+ * State handle of the client DurableObject running this sync backend. Scopes live-pull routing to
35
+ * this instance so it resets when the DO is reconstructed (see {@link handleSyncUpdateRpc}).
36
+ */
37
+ durableObjectState: CfTypes.DurableObjectState
30
38
  /** Information about this DurableObject instance so the Sync DO instance can call back to this instance */
31
39
  durableObjectContext: {
32
40
  /** See `wrangler.toml` for the binding name */
@@ -42,7 +50,11 @@ export interface DoRpcSyncOptions {
42
50
  * Used internally by `@livestore/adapter-cf` to connect to the sync backend.
43
51
  */
44
52
  export const makeDoRpcSync =
45
- ({ syncBackendStub, durableObjectContext }: DoRpcSyncOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
53
+ ({
54
+ syncBackendStub,
55
+ durableObjectState,
56
+ durableObjectContext,
57
+ }: DoRpcSyncOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
46
58
  ({ storeId, payload }) =>
47
59
  Effect.gen(function* () {
48
60
  const isConnected = yield* SubscriptionRef.make(true)
@@ -62,7 +74,7 @@ export const makeDoRpcSync =
62
74
  const backendIdHelper = yield* SyncBackend.makeBackendIdHelper
63
75
 
64
76
  const pull: SyncBackend.SyncBackend<SyncMetadata>['pull'] = (cursor, options) =>
65
- rpcClient.SyncDoRpc.Pull({
77
+ rpcClient['SyncDoRpc.Pull']({
66
78
  cursor: cursor.pipe(
67
79
  Option.map((a) => ({
68
80
  eventSequenceNumber: a.eventSequenceNumber,
@@ -70,50 +82,74 @@ export const makeDoRpcSync =
70
82
  })),
71
83
  ),
72
84
  storeId,
73
- rpcContext: options?.live ? { callerContext: durableObjectContext } : undefined,
85
+ rpcContext: options?.live === true ? { callerContext: durableObjectContext } : undefined,
74
86
  }).pipe(
75
- options?.live
87
+ options?.live === true
76
88
  ? Stream.concatWithLastElement((res) =>
77
89
  Effect.gen(function* () {
78
90
  if (res._tag === 'None')
79
91
  return shouldNeverHappen('There should at least be a no-more page info response')
80
92
 
81
- const mailbox = yield* Mailbox.make<SyncMessage.PullResponse>().pipe(
82
- Effect.acquireRelease((mailbox) => mailbox.shutdown),
93
+ const requestId = res.value.rpcRequestId
94
+ const routing = pullRoutingFor(durableObjectState)
95
+
96
+ const queue = yield* Effect.acquireRelease(Queue.unbounded<SyncMessage.PullResponse>(), (queue) =>
97
+ // Drop the routing entry on release so it can't outlive its (now shut-down) queue
98
+ Effect.sync(() => routing.delete(requestId)).pipe(Effect.andThen(Queue.shutdown(queue))),
83
99
  )
84
100
 
85
- requestIdMailboxMap.set(res.value.rpcRequestId, mailbox)
101
+ routing.set(requestId, queue)
86
102
 
87
- return Mailbox.toStream(mailbox)
88
- }).pipe(Stream.unwrapScoped),
103
+ return Stream.fromQueue(queue)
104
+ }).pipe(Stream.unwrap),
89
105
  )
90
106
  : identity,
91
107
  Stream.tap((res) => backendIdHelper.lazySet(res.backendId)),
92
- Stream.map((res) => omit(res, ['backendId'])),
93
- Stream.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
108
+ Stream.map((res) => Struct.omit(res, ['backendId'])),
109
+ Stream.mapError((cause) =>
110
+ cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
111
+ ? cause
112
+ : new UnknownError({ cause }),
113
+ ),
94
114
  Stream.withSpan('rpc-sync-client:pull'),
95
115
  )
96
116
 
97
- const push: SyncBackend.SyncBackend<{ createdAt: string }>['push'] = (batch) =>
98
- Effect.gen(function* () {
117
+ const push: SyncBackend.SyncBackend<{ createdAt: string }>['push'] = Effect.fn('rpc-sync-client:push')(
118
+ function* (batch) {
99
119
  if (batch.length === 0) {
100
120
  return
101
121
  }
102
122
 
103
- yield* rpcClient.SyncDoRpc.Push({ batch, storeId, backendId: backendIdHelper.get() })
104
- }).pipe(
105
- Effect.mapError((cause) =>
106
- cause._tag === 'InvalidPushError'
107
- ? cause
108
- : InvalidPushError.make({ cause: new UnexpectedError({ cause }) }),
109
- ),
110
- Effect.withSpan('rpc-sync-client:push'),
111
- )
123
+ const backendId = backendIdHelper.get()
124
+ if (EffectArray.isReadonlyArrayNonEmpty(batch) === false) {
125
+ return
126
+ }
112
127
 
113
- const ping: SyncBackend.SyncBackend<{ createdAt: string }>['ping'] = rpcClient.SyncDoRpc.Ping({
128
+ const batchChunks = yield* splitArrayBySize({
129
+ maxItems: MAX_PUSH_EVENTS_PER_REQUEST,
130
+ maxBytes: MAX_DO_RPC_REQUEST_BYTES,
131
+ encode: (items: ReadonlyArray<PushBatchItem>) => ({
132
+ batch: items,
133
+ storeId,
134
+ backendId,
135
+ }),
136
+ })(batch).pipe(Effect.mapError((cause) => new UnknownError({ cause })))
137
+
138
+ for (const batchChunk of batchChunks) {
139
+ yield* rpcClient['SyncDoRpc.Push']({ batch: batchChunk, storeId, backendId })
140
+ }
141
+ },
142
+ Effect.mapError((cause) =>
143
+ cause._tag === 'UnknownError' || cause._tag === 'ServerAheadError' || cause._tag === 'BackendIdMismatchError'
144
+ ? cause
145
+ : new UnknownError({ cause }),
146
+ ),
147
+ )
148
+
149
+ const ping: SyncBackend.SyncBackend<{ createdAt: string }>['ping'] = rpcClient['SyncDoRpc.Ping']({
114
150
  storeId,
115
151
  payload,
116
- }).pipe(UnexpectedError.mapToUnexpectedError, Effect.withSpan('rpc-sync-client:ping'))
152
+ }).pipe(UnknownError.mapToUnknownError, Effect.withSpan('rpc-sync-client:ping'))
117
153
 
118
154
  return SyncBackend.of({
119
155
  connect,
@@ -135,6 +171,9 @@ export const makeDoRpcSync =
135
171
  }).pipe(Effect.withSpan('rpc-sync-client:makeDoRpcSync'))
136
172
 
137
173
  /**
174
+ * Routes an update from the sync backend into this client's live pull.
175
+ *
176
+ * Only `ctx` and `payload` go here; `storeId` is for reloading your store on a rebuilt DO (see example).
138
177
  *
139
178
  * ```ts
140
179
  * import { DurableObject } from 'cloudflare:workers'
@@ -143,25 +182,31 @@ export const makeDoRpcSync =
143
182
  * export class MyDurableObject extends DurableObject implements ClientDoWithRpcCallback {
144
183
  * // ...
145
184
  *
146
- * async syncUpdateRpc(payload: RpcMessage.ResponseChunkEncoded) {
147
- * return handleSyncUpdateRpc(payload)
185
+ * async syncUpdateRpc(payload: Uint8Array<ArrayBuffer>, storeId: string) {
186
+ * await this.getStore(storeId)
187
+ * return handleSyncUpdateRpc(this.ctx, payload)
148
188
  * }
149
189
  * }
150
190
  * ```
151
191
  */
152
- export const handleSyncUpdateRpc = (payload: unknown) =>
192
+ export const handleSyncUpdateRpc = (ctx: CfTypes.DurableObjectState, payload: Uint8Array<ArrayBuffer>) =>
153
193
  Effect.gen(function* () {
154
- const decodedPayload = yield* Schema.decodeUnknown(ResponseChunkEncoded)(payload)
155
- const decoded = yield* Schema.decodeUnknown(SyncMessage.PullResponse)(decodedPayload.values[0]!)
194
+ const parser = RpcSerialization.msgPack.makeUnsafe()
195
+ const decodedMessage = parser.decode(payload)
196
+ const [response] = Array.isArray(decodedMessage) === true ? decodedMessage.flat(1) : [decodedMessage]
197
+ const decodedPayload = yield* Schema.decodeUnknownEffect(ResponseChunkEncoded)(response)
198
+ const decoded = yield* Schema.decodeUnknownEffect(Schema.toCodecJson(SyncMessage.PullResponse))(
199
+ decodedPayload.values[0],
200
+ )
156
201
 
157
- const pullStreamMailbox = requestIdMailboxMap.get(decodedPayload.requestId)
202
+ const pullStreamQueue = pullRoutingFor(ctx).get(decodedPayload.requestId)
158
203
 
159
- if (pullStreamMailbox === undefined) {
204
+ if (pullStreamQueue === undefined) {
160
205
  // Case: DO was hibernated, so we need to manually update the store
161
- yield* Effect.log(`No mailbox found for ${decodedPayload.requestId}`)
206
+ yield* Effect.log(`No pull stream queue found for ${decodedPayload.requestId}`)
162
207
  } else {
163
208
  // Case: DO was still alive, so the existing `pull` will pick up the new events
164
- yield* pullStreamMailbox.offer(decoded)
209
+ yield* Queue.offer(pullStreamQueue, decoded)
165
210
  }
166
211
  }).pipe(Effect.withSpan('rpc-sync-client:rpcCallback'), Effect.tapCauseLogPretty, Effect.runPromise)
167
212
 
@@ -169,3 +214,24 @@ const ResponseChunkEncoded = Schema.Struct({
169
214
  requestId: Schema.String,
170
215
  values: Schema.Array(Schema.Any),
171
216
  })
217
+
218
+ type EffectRpcRequestId = string // 0, 1, 2, ...
219
+ type PullRouting = Map<EffectRpcRequestId, Queue.Queue<SyncMessage.PullResponse>>
220
+
221
+ /**
222
+ * Per-instance map from a live pull's request id to its response queue, keyed off the client DO's
223
+ * `DurableObjectState` (not a module global) for two independent reasons:
224
+ * - Per-instance: a reconstructed DO starts empty, so a reverse-RPC whose store is gone hits the
225
+ * `handleSyncUpdateRpc` drop branch (the recovery hook) instead of a stale queue that outlived it.
226
+ * - Keyed by request id: a late chunk from a superseded pull generation finds no entry and is
227
+ * dropped here — never reaching `SyncState.merge`, which dies on an event at/below the upstream head.
228
+ */
229
+ const pullRoutingByInstance = new WeakMap<CfTypes.DurableObjectState, PullRouting>()
230
+
231
+ const pullRoutingFor = (ctx: CfTypes.DurableObjectState): PullRouting => {
232
+ const existing = pullRoutingByInstance.get(ctx)
233
+ if (existing !== undefined) return existing
234
+ const routing: PullRouting = new Map()
235
+ pullRoutingByInstance.set(ctx, routing)
236
+ return routing
237
+ }
@@ -1,8 +1,7 @@
1
- import { InvalidPullError, InvalidPushError, SyncBackend, UnexpectedError } from '@livestore/common'
2
- import type { EventSequenceNumber } from '@livestore/common/schema'
3
- import { omit } from '@livestore/utils'
1
+ import { SyncBackend, UnknownError } from '@livestore/common'
2
+ import type { EventSequenceNumber, LiveStoreEvent } from '@livestore/common/schema'
3
+ import { splitArrayBySize } from '@livestore/common/sync'
4
4
  import {
5
- Chunk,
6
5
  type Duration,
7
6
  Effect,
8
7
  HttpClient,
@@ -10,18 +9,25 @@ import {
10
9
  identity,
11
10
  Layer,
12
11
  Option,
12
+ ReadonlyArray as EffectArray,
13
13
  RpcClient,
14
14
  RpcSerialization,
15
15
  Schedule,
16
16
  Schema,
17
+ Semaphore,
17
18
  Stream,
19
+ Struct,
18
20
  SubscriptionRef,
19
21
  UrlParams,
20
22
  } from '@livestore/utils/effect'
23
+
24
+ import { MAX_HTTP_REQUEST_BYTES, MAX_PUSH_EVENTS_PER_REQUEST } from '../../common/constants.ts'
21
25
  import { SyncHttpRpc } from '../../common/http-rpc-schema.ts'
22
26
  import { SearchParamsSchema } from '../../common/mod.ts'
23
27
  import type { SyncMetadata } from '../../common/sync-message-types.ts'
24
28
 
29
+ type PushBatchItem = LiveStoreEvent.Global.Encoded
30
+
25
31
  export interface HttpSyncOptions {
26
32
  /**
27
33
  * URL of the sync backend
@@ -38,7 +44,7 @@ export interface HttpSyncOptions {
38
44
  * How often to poll for new events
39
45
  * @default 5 seconds
40
46
  */
41
- pollInterval?: Duration.DurationInput
47
+ pollInterval?: Duration.Input
42
48
  }
43
49
  ping?: {
44
50
  /**
@@ -49,12 +55,12 @@ export interface HttpSyncOptions {
49
55
  * How long to wait for a ping response before timing out
50
56
  * @default 10 seconds
51
57
  */
52
- requestTimeout?: Duration.DurationInput
58
+ requestTimeout?: Duration.Input
53
59
  /**
54
60
  * How often to send ping requests
55
61
  * @default 10 seconds
56
62
  */
57
- requestInterval?: Duration.DurationInput
63
+ requestInterval?: Duration.Input
58
64
  }
59
65
  }
60
66
 
@@ -70,19 +76,20 @@ export const makeHttpSync =
70
76
 
71
77
  const livePullInterval = options.livePull?.pollInterval ?? 5_000
72
78
 
73
- const urlParamsData = yield* Schema.encode(SearchParamsSchema)({
79
+ const urlParamsData = yield* Schema.encodeEffect(SearchParamsSchema)({
74
80
  storeId,
75
81
  payload,
76
82
  transport: 'http',
77
- }).pipe(UnexpectedError.mapToUnexpectedError)
83
+ }).pipe(UnknownError.mapToUnknownError)
78
84
 
79
85
  const urlParams = UrlParams.fromInput(urlParamsData)
80
86
 
81
87
  // Setup HTTP RPC Protocol
82
88
  const HttpProtocolLive = RpcClient.layerProtocolHttp({
83
- url: `${options.url}?${UrlParams.toString(urlParams)}`,
89
+ url: options.url,
84
90
  transformClient: HttpClient.mapRequest((request) =>
85
91
  request.pipe(
92
+ HttpClientRequest.appendUrlParams(urlParams),
86
93
  HttpClientRequest.setHeaders({
87
94
  ...options.headers,
88
95
  // Used in CF Worker to identify the store (additionally to storeId embedded in the RPC requests)
@@ -97,13 +104,13 @@ export const makeHttpSync =
97
104
  const pingTimeout = options.ping?.requestTimeout ?? 10_000
98
105
 
99
106
  const ping: SyncBackend.SyncBackend<SyncMetadata>['ping'] = Effect.gen(function* () {
100
- yield* rpcClient.SyncHttpRpc.Ping({ storeId, payload })
107
+ yield* rpcClient['SyncHttpRpc.Ping']({ storeId, payload })
101
108
 
102
109
  yield* SubscriptionRef.set(isConnected, true)
103
110
  }).pipe(
104
- UnexpectedError.mapToUnexpectedError,
111
+ UnknownError.mapToUnknownError,
105
112
  Effect.timeout(pingTimeout),
106
- Effect.catchTag('TimeoutException', () => SubscriptionRef.set(isConnected, false)),
113
+ Effect.catchTag('TimeoutError', () => SubscriptionRef.set(isConnected, false)),
107
114
  )
108
115
 
109
116
  const pingInterval = options.ping?.requestInterval ?? 10_000
@@ -114,76 +121,105 @@ export const makeHttpSync =
114
121
  }
115
122
 
116
123
  // Helps already establish a TCP connection to the server
117
- const connect = ping.pipe(UnexpectedError.mapToUnexpectedError)
124
+ const connect = ping.pipe(UnknownError.mapToUnknownError)
118
125
 
119
126
  const backendIdHelper = yield* SyncBackend.makeBackendIdHelper
120
127
 
121
128
  const mapCursor = (cursor: Option.Option<{ eventSequenceNumber: number }>) =>
122
129
  cursor.pipe(
123
130
  Option.map((a) => ({
124
- eventSequenceNumber: a.eventSequenceNumber as EventSequenceNumber.GlobalEventSequenceNumber,
131
+ eventSequenceNumber: a.eventSequenceNumber as EventSequenceNumber.Global.Type,
125
132
  backendId: backendIdHelper.get().pipe(Option.getOrThrow),
126
133
  })),
127
134
  )
128
135
 
129
136
  const pull: SyncBackend.SyncBackend<SyncMetadata>['pull'] = (cursor, options) =>
130
- rpcClient.SyncHttpRpc.Pull({
137
+ rpcClient['SyncHttpRpc.Pull']({
131
138
  storeId,
132
139
  payload,
133
140
  cursor: mapCursor(cursor),
134
141
  }).pipe(
135
- options?.live
142
+ options?.live === true
136
143
  ? // Phase 2: Simulate `live` pull by polling for new events
137
144
  Stream.concatWithLastElement((lastElement) => {
138
145
  const initialPhase2Cursor = lastElement.pipe(
139
- Option.flatMap((_) => Option.fromNullable(_.batch.at(-1)?.eventEncoded.seqNum)),
146
+ Option.flatMap((_) => Option.fromNullishOr(_.batch.at(-1)?.eventEncoded.seqNum)),
140
147
  Option.map((eventSequenceNumber) => ({ eventSequenceNumber })),
141
148
  Option.orElse(() => cursor),
142
149
  mapCursor,
143
150
  )
144
151
 
145
- return Stream.unfoldChunkEffect(initialPhase2Cursor, (currentCursor) =>
152
+ return Stream.paginate(initialPhase2Cursor, (currentCursor) =>
146
153
  Effect.gen(function* () {
147
154
  yield* Effect.sleep(livePullInterval)
148
155
 
149
- const items = yield* rpcClient.SyncHttpRpc.Pull({ storeId, payload, cursor: currentCursor }).pipe(
150
- Stream.runCollect,
151
- )
156
+ const items = yield* rpcClient['SyncHttpRpc.Pull']({
157
+ storeId,
158
+ payload,
159
+ cursor: currentCursor,
160
+ }).pipe(Stream.runCollect)
152
161
 
153
- const nextCursor = Chunk.last(items).pipe(
154
- Option.flatMap((item) => Option.fromNullable(item.batch.at(-1)?.eventEncoded.seqNum)),
162
+ const nextCursor = EffectArray.last(items).pipe(
163
+ Option.flatMap((item) => Option.fromNullishOr(item.batch.at(-1)?.eventEncoded.seqNum)),
155
164
  Option.map((eventSequenceNumber) => ({ eventSequenceNumber })),
156
165
  Option.orElse(() => currentCursor),
157
166
  mapCursor,
158
167
  )
159
168
 
160
- return Option.some([items, nextCursor])
169
+ const page: readonly [typeof items, Option.Option<typeof currentCursor>] = [
170
+ items,
171
+ Option.some(nextCursor),
172
+ ]
173
+ return page
161
174
  }),
162
175
  )
163
176
  })
164
177
  : identity,
165
178
  Stream.tap((res) => backendIdHelper.lazySet(res.backendId)),
166
- Stream.map((res) => omit(res, ['backendId'])),
167
- Stream.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
179
+ Stream.map((res) => Struct.omit(res, ['backendId'])),
180
+ Stream.mapError((cause) =>
181
+ cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
182
+ ? cause
183
+ : new UnknownError({ cause }),
184
+ ),
168
185
  Stream.withSpan('http-sync-client:pull'),
169
186
  )
170
187
 
171
- const pushSemaphore = yield* Effect.makeSemaphore(1)
188
+ const pushSemaphore = yield* Semaphore.make(1)
172
189
 
173
- const push: SyncBackend.SyncBackend<SyncMetadata>['push'] = (batch) =>
174
- Effect.gen(function* () {
190
+ const push: SyncBackend.SyncBackend<SyncMetadata>['push'] = Effect.fn('http-sync-client:push')(
191
+ function* (batch) {
175
192
  if (batch.length === 0) {
176
193
  return
177
194
  }
178
195
 
179
- yield* rpcClient.SyncHttpRpc.Push({ storeId, payload, batch, backendId: backendIdHelper.get() })
180
- }).pipe(
181
- pushSemaphore.withPermits(1),
182
- Effect.mapError((cause) =>
183
- cause._tag === 'InvalidPushError' ? cause : new InvalidPushError({ cause: new UnexpectedError({ cause }) }),
184
- ),
185
- Effect.withSpan('http-sync-client:push'),
186
- )
196
+ const backendId = backendIdHelper.get()
197
+ if (EffectArray.isReadonlyArrayNonEmpty(batch) === false) {
198
+ return
199
+ }
200
+
201
+ const batchChunks = yield* splitArrayBySize({
202
+ maxItems: MAX_PUSH_EVENTS_PER_REQUEST,
203
+ maxBytes: MAX_HTTP_REQUEST_BYTES,
204
+ encode: (items: ReadonlyArray<PushBatchItem>) => ({
205
+ batch: items,
206
+ storeId,
207
+ payload,
208
+ backendId,
209
+ }),
210
+ })(batch).pipe(Effect.mapError((cause) => new UnknownError({ cause })))
211
+
212
+ for (const batchChunk of batchChunks) {
213
+ yield* rpcClient['SyncHttpRpc.Push']({ storeId, payload, batch: batchChunk, backendId })
214
+ }
215
+ },
216
+ pushSemaphore.withPermits(1),
217
+ Effect.mapError((cause) =>
218
+ cause._tag === 'UnknownError' || cause._tag === 'ServerAheadError' || cause._tag === 'BackendIdMismatchError'
219
+ ? cause
220
+ : new UnknownError({ cause }),
221
+ ),
222
+ )
187
223
 
188
224
  return SyncBackend.of({
189
225
  connect,
@@ -1,10 +1,12 @@
1
- import { InvalidPullError, InvalidPushError, IsOfflineError, SyncBackend, UnexpectedError } from '@livestore/common'
2
- import { omit } from '@livestore/utils'
1
+ import { IsOfflineError, SyncBackend, UnknownError } from '@livestore/common'
2
+ import type { LiveStoreEvent } from '@livestore/common/schema'
3
+ import { splitArrayBySize } from '@livestore/common/sync'
3
4
  import {
4
- type Duration,
5
+ Duration,
5
6
  Effect,
6
7
  Layer,
7
8
  Option,
9
+ ReadonlyArray as EffectArray,
8
10
  RpcClient,
9
11
  RpcSerialization,
10
12
  Schedule,
@@ -12,10 +14,13 @@ import {
12
14
  type Scope,
13
15
  Socket,
14
16
  Stream,
17
+ Struct,
15
18
  SubscriptionRef,
16
19
  UrlParams,
17
- type WebSocket,
18
20
  } from '@livestore/utils/effect'
21
+ import type { WebSocket } from '@livestore/utils/effect/browser'
22
+
23
+ import { MAX_PUSH_EVENTS_PER_REQUEST, MAX_WS_MESSAGE_BYTES } from '../../common/constants.ts'
19
24
  import { SearchParamsSchema } from '../../common/mod.ts'
20
25
  import type { SyncMetadata } from '../../common/sync-message-types.ts'
21
26
  import { SyncWsRpc } from '../../common/ws-rpc-schema.ts'
@@ -43,12 +48,12 @@ export interface WsSyncOptions {
43
48
  * How long to wait for a ping response before timing out
44
49
  * @default 10 seconds
45
50
  */
46
- requestTimeout?: Duration.DurationInput
51
+ requestTimeout?: Duration.Input
47
52
  /**
48
53
  * How often to send ping requests
49
54
  * @default 10 seconds
50
55
  */
51
- requestInterval?: Duration.DurationInput
56
+ requestInterval?: Duration.Input
52
57
  }
53
58
  }
54
59
 
@@ -65,11 +70,11 @@ export const makeWsSync =
65
70
  (options: WsSyncOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
66
71
  ({ storeId, payload }) =>
67
72
  Effect.gen(function* () {
68
- const urlParamsData = yield* Schema.encode(SearchParamsSchema)({
73
+ const urlParamsData = yield* Schema.encodeEffect(SearchParamsSchema)({
69
74
  storeId,
70
75
  payload,
71
76
  transport: 'ws',
72
- }).pipe(UnexpectedError.mapToUnexpectedError)
77
+ }).pipe(UnknownError.mapToUnknownError)
73
78
 
74
79
  const urlParams = UrlParams.fromInput(urlParamsData)
75
80
  const wsUrl = `${options.url}?${UrlParams.toString(urlParams)}`
@@ -84,15 +89,18 @@ export const makeWsSync =
84
89
  // }
85
90
  // TODO bring this back in a cross-platform way
86
91
  // if (navigator.onLine === false) {
87
- // yield* Effect.async((cb) => self.addEventListener('online', () => cb(Effect.void)))
92
+ // yield* Effect.callback((cb) => self.addEventListener('online', () => cb(Effect.void)))
88
93
  // }
89
94
 
90
95
  const pingInterval = options.ping?.requestInterval ?? 10_000
91
96
 
92
97
  const ProtocolLive = RpcClient.layerProtocolSocketWithIsConnected({
93
98
  isConnected,
94
- retryTransientErrors: Schedule.fixed(1000),
95
- pingSchedule: Schedule.once.pipe(Schedule.andThen(Schedule.fixed(pingInterval))),
99
+ retryTransientErrors: Schedule.exponential('1 seconds').pipe(
100
+ Schedule.modifyDelay(({ duration }) => Effect.succeed(Duration.min(duration, Duration.seconds(30)))),
101
+ Schedule.jittered,
102
+ ),
103
+ pingSchedule: Schedule.recurs(1).pipe(Schedule.concat(Schedule.fixed(pingInterval))),
96
104
  url: wsUrl,
97
105
  }).pipe(
98
106
  Layer.provide(Socket.layerWebSocket(wsUrl)),
@@ -114,8 +122,8 @@ export const makeWsSync =
114
122
  yield* SubscriptionRef.set(isConnected, true)
115
123
  }).pipe(
116
124
  Effect.timeout(pingTimeout),
117
- Effect.catchTag('TimeoutException', () => SubscriptionRef.set(isConnected, false)),
118
- UnexpectedError.mapToUnexpectedError,
125
+ Effect.catchTag('TimeoutError', () => SubscriptionRef.set(isConnected, false)),
126
+ UnknownError.mapToUnknownError,
119
127
  Effect.withSpan('ping'),
120
128
  )
121
129
 
@@ -125,7 +133,7 @@ export const makeWsSync =
125
133
  isConnected,
126
134
  connect: ping,
127
135
  pull: (cursor, options) =>
128
- rpcClient.SyncWsRpc.Pull({
136
+ rpcClient['SyncWsRpc.Pull']({
129
137
  storeId,
130
138
  payload,
131
139
  cursor: cursor.pipe(
@@ -134,39 +142,57 @@ export const makeWsSync =
134
142
  backendId: backendIdHelper.get().pipe(Option.getOrThrow),
135
143
  })),
136
144
  ),
137
- live: options?.live ?? false,
145
+ live: options?.live === true,
138
146
  }).pipe(
139
147
  Stream.tap((res) => backendIdHelper.lazySet(res.backendId)),
140
- Stream.map((res) => omit(res, ['backendId'])),
148
+ Stream.map((res) => Struct.omit(res, ['backendId'])),
141
149
  Stream.mapError((cause) =>
142
- cause._tag === 'RpcClientError' && Socket.isSocketError(cause.cause)
150
+ cause._tag === 'RpcClientError' && Socket.isSocketError(cause.cause) === true
143
151
  ? new IsOfflineError({ cause: cause.cause })
144
- : cause._tag === 'InvalidPullError'
152
+ : cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
145
153
  ? cause
146
- : InvalidPullError.make({ cause }),
154
+ : new UnknownError({ cause }),
147
155
  ),
148
156
  Stream.withSpan('pull'),
149
157
  ),
150
158
 
151
- push: (batch) =>
152
- Effect.gen(function* () {
153
- if (batch.length === 0) {
154
- return
155
- }
159
+ push: Effect.fn('push')(function* (batch) {
160
+ if (batch.length === 0) return
161
+
162
+ const encodePayload = (batch: ReadonlyArray<LiveStoreEvent.Global.Encoded>) => ({
163
+ storeId,
164
+ payload,
165
+ batch,
166
+ backendId: backendIdHelper.get(),
167
+ })
168
+
169
+ if (EffectArray.isReadonlyArrayNonEmpty(batch) === false) {
170
+ return
171
+ }
172
+
173
+ const batchChunks = yield* splitArrayBySize({
174
+ maxItems: MAX_PUSH_EVENTS_PER_REQUEST,
175
+ maxBytes: MAX_WS_MESSAGE_BYTES,
176
+ encode: encodePayload,
177
+ })(batch).pipe(Effect.mapError((cause) => new UnknownError({ cause })))
156
178
 
157
- return yield* rpcClient.SyncWsRpc.Push({
179
+ for (const batchChunk of batchChunks) {
180
+ yield* rpcClient['SyncWsRpc.Push']({
158
181
  storeId,
159
182
  payload,
160
- batch,
183
+ batch: batchChunk,
161
184
  backendId: backendIdHelper.get(),
162
185
  }).pipe(
163
186
  Effect.mapError((cause) =>
164
- cause._tag === 'InvalidPushError'
187
+ cause._tag === 'UnknownError' ||
188
+ cause._tag === 'ServerAheadError' ||
189
+ cause._tag === 'BackendIdMismatchError'
165
190
  ? cause
166
- : new InvalidPushError({ cause: new UnexpectedError({ cause }) }),
191
+ : new UnknownError({ cause }),
167
192
  ),
168
193
  )
169
- }).pipe(Effect.withSpan('push')),
194
+ }
195
+ }),
170
196
  ping,
171
197
  metadata: {
172
198
  name: '@livestore/cf-sync',
@@ -0,0 +1,18 @@
1
+ // Shared transport limits for Cloudflare sync provider
2
+ // Keep payloads comfortably below ~1MB frame caps across Cloudflare transports.
3
+ // References:
4
+ // - Durable Objects WebSockets + hibernation best practices:
5
+ // https://developers.cloudflare.com/durable-objects/best-practices/websockets/
6
+ // - Workers platform limits (general context):
7
+ // https://developers.cloudflare.com/workers/platform/limits/
8
+ // Empirically, frames just below 1MB can fail on hibernated DO WebSockets; we use 900_000 bytes to keep a safety margin.
9
+ export const MAX_TRANSPORT_PAYLOAD_BYTES = 900_000
10
+
11
+ export const MAX_WS_MESSAGE_BYTES = MAX_TRANSPORT_PAYLOAD_BYTES
12
+ export const MAX_DO_RPC_REQUEST_BYTES = MAX_TRANSPORT_PAYLOAD_BYTES
13
+ export const MAX_HTTP_REQUEST_BYTES = MAX_TRANSPORT_PAYLOAD_BYTES
14
+
15
+ // Upper bound for items per message/request. Mirrors server broadcast chunking.
16
+ // Not Cloudflare-enforced; chosen to balance payload size and latency.
17
+ export const MAX_PULL_EVENTS_PER_MESSAGE = 100
18
+ export const MAX_PUSH_EVENTS_PER_REQUEST = 100