@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.
- package/README.md +7 -8
- package/dist/.tsbuildinfo +1 -1
- package/dist/cf-worker/do/durable-object.d.ts +1 -1
- package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
- package/dist/cf-worker/do/durable-object.js +36 -18
- package/dist/cf-worker/do/durable-object.js.map +1 -1
- package/dist/cf-worker/do/layer.d.ts +24 -20
- package/dist/cf-worker/do/layer.d.ts.map +1 -1
- package/dist/cf-worker/do/layer.js +57 -24
- package/dist/cf-worker/do/layer.js.map +1 -1
- package/dist/cf-worker/do/pull.d.ts +8 -3
- package/dist/cf-worker/do/pull.d.ts.map +1 -1
- package/dist/cf-worker/do/pull.js +26 -21
- package/dist/cf-worker/do/pull.js.map +1 -1
- package/dist/cf-worker/do/push.d.ts +20 -6
- package/dist/cf-worker/do/push.d.ts.map +1 -1
- package/dist/cf-worker/do/push.js +84 -56
- package/dist/cf-worker/do/push.js.map +1 -1
- package/dist/cf-worker/do/push.test.d.ts +2 -0
- package/dist/cf-worker/do/push.test.d.ts.map +1 -0
- package/dist/cf-worker/do/push.test.js +30 -0
- package/dist/cf-worker/do/push.test.js.map +1 -0
- package/dist/cf-worker/do/sqlite.d.ts +49 -52
- package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
- package/dist/cf-worker/do/sqlite.js +14 -5
- package/dist/cf-worker/do/sqlite.js.map +1 -1
- package/dist/cf-worker/do/sync-storage.d.ts +15 -9
- package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
- package/dist/cf-worker/do/sync-storage.js +111 -25
- package/dist/cf-worker/do/sync-storage.js.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts +2 -2
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.js +31 -23
- package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts +5 -3
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/http-rpc-server.js +28 -19
- package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +3 -2
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.js +32 -10
- package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
- package/dist/cf-worker/shared.d.ts +124 -34
- package/dist/cf-worker/shared.d.ts.map +1 -1
- package/dist/cf-worker/shared.js +52 -17
- package/dist/cf-worker/shared.js.map +1 -1
- package/dist/cf-worker/worker.d.ts +47 -39
- package/dist/cf-worker/worker.d.ts.map +1 -1
- package/dist/cf-worker/worker.js +54 -35
- package/dist/cf-worker/worker.js.map +1 -1
- package/dist/client/transport/do-rpc-client.d.ts +13 -4
- package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/do-rpc-client.js +74 -28
- package/dist/client/transport/do-rpc-client.js.map +1 -1
- package/dist/client/transport/http-rpc-client.d.ts +3 -3
- package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/http-rpc-client.js +51 -22
- package/dist/client/transport/http-rpc-client.js.map +1 -1
- package/dist/client/transport/ws-rpc-client.d.ts +4 -3
- package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/ws-rpc-client.js +40 -22
- package/dist/client/transport/ws-rpc-client.js.map +1 -1
- package/dist/common/constants.d.ts +7 -0
- package/dist/common/constants.d.ts.map +1 -0
- package/dist/common/constants.js +17 -0
- package/dist/common/constants.js.map +1 -0
- package/dist/common/do-rpc-schema.d.ts +42 -43
- package/dist/common/do-rpc-schema.d.ts.map +1 -1
- package/dist/common/do-rpc-schema.js +5 -5
- package/dist/common/do-rpc-schema.js.map +1 -1
- package/dist/common/http-rpc-schema.d.ts +37 -38
- package/dist/common/http-rpc-schema.d.ts.map +1 -1
- package/dist/common/http-rpc-schema.js +7 -7
- package/dist/common/http-rpc-schema.js.map +1 -1
- package/dist/common/mod.d.ts +5 -3
- package/dist/common/mod.d.ts.map +1 -1
- package/dist/common/mod.js +4 -2
- package/dist/common/mod.js.map +1 -1
- package/dist/common/sync-message-types.d.ts +113 -130
- package/dist/common/sync-message-types.d.ts.map +1 -1
- package/dist/common/sync-message-types.js +29 -17
- package/dist/common/sync-message-types.js.map +1 -1
- package/dist/common/ws-rpc-schema.d.ts +34 -35
- package/dist/common/ws-rpc-schema.d.ts.map +1 -1
- package/dist/common/ws-rpc-schema.js +5 -5
- package/dist/common/ws-rpc-schema.js.map +1 -1
- package/package.json +57 -15
- package/src/cf-worker/do/durable-object.ts +59 -41
- package/src/cf-worker/do/layer.ts +153 -88
- package/src/cf-worker/do/pull.ts +48 -31
- package/src/cf-worker/do/push.test.ts +40 -0
- package/src/cf-worker/do/push.ts +163 -125
- package/src/cf-worker/do/sqlite.ts +15 -5
- package/src/cf-worker/do/sync-storage.ts +171 -38
- package/src/cf-worker/do/transport/do-rpc-server.ts +40 -40
- package/src/cf-worker/do/transport/http-rpc-server.ts +37 -20
- package/src/cf-worker/do/transport/ws-rpc-server.ts +44 -16
- package/src/cf-worker/shared.ts +152 -36
- package/src/cf-worker/worker.ts +110 -58
- package/src/client/transport/do-rpc-client.ts +106 -40
- package/src/client/transport/http-rpc-client.ts +74 -38
- package/src/client/transport/ws-rpc-client.ts +55 -29
- package/src/common/constants.ts +18 -0
- package/src/common/do-rpc-schema.ts +6 -5
- package/src/common/http-rpc-schema.ts +8 -7
- package/src/common/mod.ts +4 -3
- package/src/common/sync-message-types.ts +19 -19
- package/src/common/ws-rpc-schema.ts +6 -5
- package/dist/cf-worker/do/ws-chunking.d.ts +0 -22
- package/dist/cf-worker/do/ws-chunking.d.ts.map +0 -1
- package/dist/cf-worker/do/ws-chunking.js +0 -49
- package/dist/cf-worker/do/ws-chunking.js.map +0 -1
- package/src/cf-worker/do/ws-chunking.ts +0 -76
|
@@ -1,32 +1,40 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { SyncBackend, UnknownError } from '@livestore/common'
|
|
2
2
|
import { type CfTypes, layerProtocolDurableObject } from '@livestore/common-cf'
|
|
3
|
-
import {
|
|
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
|
-
|
|
26
|
+
type PushBatchItem = SyncMessage.PushRequest['batch'][number]
|
|
22
27
|
|
|
23
|
-
|
|
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
|
-
({
|
|
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
|
|
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
|
|
82
|
-
|
|
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
|
-
|
|
101
|
+
routing.set(requestId, queue)
|
|
86
102
|
|
|
87
|
-
return
|
|
88
|
-
}).pipe(Stream.
|
|
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) =>
|
|
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'] = (
|
|
98
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
147
|
-
*
|
|
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:
|
|
192
|
+
export const handleSyncUpdateRpc = (ctx: CfTypes.DurableObjectState, payload: Uint8Array<ArrayBuffer>) =>
|
|
153
193
|
Effect.gen(function* () {
|
|
154
|
-
const
|
|
155
|
-
const
|
|
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
|
|
202
|
+
const pullStreamQueue = pullRoutingFor(ctx).get(decodedPayload.requestId)
|
|
158
203
|
|
|
159
|
-
if (
|
|
204
|
+
if (pullStreamQueue === undefined) {
|
|
160
205
|
// Case: DO was hibernated, so we need to manually update the store
|
|
161
|
-
yield* Effect.log(`No
|
|
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*
|
|
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 {
|
|
2
|
-
import type { EventSequenceNumber } from '@livestore/common/schema'
|
|
3
|
-
import {
|
|
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.
|
|
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.
|
|
58
|
+
requestTimeout?: Duration.Input
|
|
53
59
|
/**
|
|
54
60
|
* How often to send ping requests
|
|
55
61
|
* @default 10 seconds
|
|
56
62
|
*/
|
|
57
|
-
requestInterval?: Duration.
|
|
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.
|
|
79
|
+
const urlParamsData = yield* Schema.encodeEffect(SearchParamsSchema)({
|
|
74
80
|
storeId,
|
|
75
81
|
payload,
|
|
76
82
|
transport: 'http',
|
|
77
|
-
}).pipe(
|
|
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:
|
|
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
|
|
107
|
+
yield* rpcClient['SyncHttpRpc.Ping']({ storeId, payload })
|
|
101
108
|
|
|
102
109
|
yield* SubscriptionRef.set(isConnected, true)
|
|
103
110
|
}).pipe(
|
|
104
|
-
|
|
111
|
+
UnknownError.mapToUnknownError,
|
|
105
112
|
Effect.timeout(pingTimeout),
|
|
106
|
-
Effect.catchTag('
|
|
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(
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
152
|
+
return Stream.paginate(initialPhase2Cursor, (currentCursor) =>
|
|
146
153
|
Effect.gen(function* () {
|
|
147
154
|
yield* Effect.sleep(livePullInterval)
|
|
148
155
|
|
|
149
|
-
const items = yield* rpcClient
|
|
150
|
-
|
|
151
|
-
|
|
156
|
+
const items = yield* rpcClient['SyncHttpRpc.Pull']({
|
|
157
|
+
storeId,
|
|
158
|
+
payload,
|
|
159
|
+
cursor: currentCursor,
|
|
160
|
+
}).pipe(Stream.runCollect)
|
|
152
161
|
|
|
153
|
-
const nextCursor =
|
|
154
|
-
Option.flatMap((item) => Option.
|
|
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
|
-
|
|
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) =>
|
|
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*
|
|
188
|
+
const pushSemaphore = yield* Semaphore.make(1)
|
|
172
189
|
|
|
173
|
-
const push: SyncBackend.SyncBackend<SyncMetadata>['push'] = (
|
|
174
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
-
|
|
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.
|
|
51
|
+
requestTimeout?: Duration.Input
|
|
47
52
|
/**
|
|
48
53
|
* How often to send ping requests
|
|
49
54
|
* @default 10 seconds
|
|
50
55
|
*/
|
|
51
|
-
requestInterval?: Duration.
|
|
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.
|
|
73
|
+
const urlParamsData = yield* Schema.encodeEffect(SearchParamsSchema)({
|
|
69
74
|
storeId,
|
|
70
75
|
payload,
|
|
71
76
|
transport: 'ws',
|
|
72
|
-
}).pipe(
|
|
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.
|
|
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.
|
|
95
|
-
|
|
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('
|
|
118
|
-
|
|
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
|
|
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
|
|
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 === '
|
|
152
|
+
: cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
|
|
145
153
|
? cause
|
|
146
|
-
:
|
|
154
|
+
: new UnknownError({ cause }),
|
|
147
155
|
),
|
|
148
156
|
Stream.withSpan('pull'),
|
|
149
157
|
),
|
|
150
158
|
|
|
151
|
-
push: (batch)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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 === '
|
|
187
|
+
cause._tag === 'UnknownError' ||
|
|
188
|
+
cause._tag === 'ServerAheadError' ||
|
|
189
|
+
cause._tag === 'BackendIdMismatchError'
|
|
165
190
|
? cause
|
|
166
|
-
: new
|
|
191
|
+
: new UnknownError({ cause }),
|
|
167
192
|
),
|
|
168
193
|
)
|
|
169
|
-
}
|
|
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
|