@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.
- package/README.md +5 -5
- package/dist/.tsbuildinfo +1 -1
- package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
- package/dist/cf-worker/do/durable-object.js +53 -36
- package/dist/cf-worker/do/durable-object.js.map +1 -1
- package/dist/cf-worker/do/layer.d.ts +22 -18
- package/dist/cf-worker/do/layer.d.ts.map +1 -1
- package/dist/cf-worker/do/layer.js +45 -35
- package/dist/cf-worker/do/layer.js.map +1 -1
- package/dist/cf-worker/do/observability.d.ts +23 -0
- package/dist/cf-worker/do/observability.d.ts.map +1 -0
- package/dist/cf-worker/do/observability.js +52 -0
- package/dist/cf-worker/do/observability.js.map +1 -0
- package/dist/cf-worker/do/observability.test.d.ts +2 -0
- package/dist/cf-worker/do/observability.test.d.ts.map +1 -0
- package/dist/cf-worker/do/observability.test.js +204 -0
- package/dist/cf-worker/do/observability.test.js.map +1 -0
- package/dist/cf-worker/do/pull.d.ts +3 -3
- package/dist/cf-worker/do/pull.d.ts.map +1 -1
- package/dist/cf-worker/do/pull.js +19 -20
- package/dist/cf-worker/do/pull.js.map +1 -1
- package/dist/cf-worker/do/push.d.ts +16 -3
- package/dist/cf-worker/do/push.d.ts.map +1 -1
- package/dist/cf-worker/do/push.js +74 -53
- 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 +34 -46
- package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
- package/dist/cf-worker/do/sqlite.js +2 -2
- package/dist/cf-worker/do/sqlite.js.map +1 -1
- package/dist/cf-worker/do/sync-storage.d.ts +7 -6
- package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
- package/dist/cf-worker/do/sync-storage.js +29 -17
- package/dist/cf-worker/do/sync-storage.js.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts +17 -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 +41 -23
- package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.test.d.ts +2 -0
- package/dist/cf-worker/do/transport/do-rpc-server.test.d.ts.map +1 -0
- package/dist/cf-worker/do/transport/do-rpc-server.test.js +28 -0
- package/dist/cf-worker/do/transport/do-rpc-server.test.js.map +1 -0
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts +2 -2
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/http-rpc-server.js +12 -12
- package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +9 -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 +37 -29
- package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
- package/dist/cf-worker/mod.js +3 -3
- package/dist/cf-worker/shared.d.ts +37 -29
- package/dist/cf-worker/shared.d.ts.map +1 -1
- package/dist/cf-worker/shared.js +14 -12
- package/dist/cf-worker/shared.js.map +1 -1
- package/dist/cf-worker/worker.d.ts +9 -9
- package/dist/cf-worker/worker.d.ts.map +1 -1
- package/dist/cf-worker/worker.js +17 -15
- package/dist/cf-worker/worker.js.map +1 -1
- package/dist/client/mod.js +3 -3
- package/dist/client/transport/do-rpc-client.d.ts +18 -6
- package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/do-rpc-client.js +112 -38
- package/dist/client/transport/do-rpc-client.js.map +1 -1
- package/dist/client/transport/do-rpc-client.test.d.ts +2 -0
- package/dist/client/transport/do-rpc-client.test.d.ts.map +1 -0
- package/dist/client/transport/do-rpc-client.test.js +268 -0
- package/dist/client/transport/do-rpc-client.test.js.map +1 -0
- 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 +48 -29
- package/dist/client/transport/http-rpc-client.js.map +1 -1
- package/dist/client/transport/rpc-client-error.d.ts +5 -0
- package/dist/client/transport/rpc-client-error.d.ts.map +1 -0
- package/dist/client/transport/rpc-client-error.js +8 -0
- package/dist/client/transport/rpc-client-error.js.map +1 -0
- package/dist/client/transport/rpc-client-error.test.d.ts +2 -0
- package/dist/client/transport/rpc-client-error.test.d.ts.map +1 -0
- package/dist/client/transport/rpc-client-error.test.js +33 -0
- package/dist/client/transport/rpc-client-error.test.js.map +1 -0
- package/dist/client/transport/ws-rpc-client.d.ts +3 -3
- package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/ws-rpc-client.js +33 -23
- package/dist/client/transport/ws-rpc-client.js.map +1 -1
- package/dist/common/do-rpc-schema.d.ts +53 -41
- package/dist/common/do-rpc-schema.d.ts.map +1 -1
- package/dist/common/do-rpc-schema.js +13 -4
- package/dist/common/do-rpc-schema.js.map +1 -1
- package/dist/common/http-rpc-schema.d.ts +38 -39
- package/dist/common/http-rpc-schema.d.ts.map +1 -1
- package/dist/common/http-rpc-schema.js +6 -6
- package/dist/common/http-rpc-schema.js.map +1 -1
- package/dist/common/mod.d.ts +5 -5
- package/dist/common/mod.d.ts.map +1 -1
- package/dist/common/mod.js +7 -7
- package/dist/common/mod.js.map +1 -1
- package/dist/common/sync-message-types.d.ts +116 -133
- package/dist/common/sync-message-types.d.ts.map +1 -1
- package/dist/common/sync-message-types.js +26 -14
- package/dist/common/sync-message-types.js.map +1 -1
- package/dist/common/ws-rpc-schema.d.ts +33 -34
- 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 +42 -58
- package/src/cf-worker/do/durable-object.ts +70 -64
- package/src/cf-worker/do/layer.ts +142 -99
- package/src/cf-worker/do/observability.test.ts +337 -0
- package/src/cf-worker/do/observability.ts +121 -0
- package/src/cf-worker/do/pull.ts +28 -28
- package/src/cf-worker/do/push.test.ts +40 -0
- package/src/cf-worker/do/push.ts +139 -115
- package/src/cf-worker/do/sqlite.ts +1 -1
- package/src/cf-worker/do/sync-storage.ts +34 -21
- package/src/cf-worker/do/transport/do-rpc-server.test.ts +32 -0
- package/src/cf-worker/do/transport/do-rpc-server.ts +46 -35
- package/src/cf-worker/do/transport/http-rpc-server.ts +7 -10
- package/src/cf-worker/do/transport/ws-rpc-server.ts +52 -28
- package/src/cf-worker/shared.ts +29 -17
- package/src/cf-worker/worker.ts +21 -22
- package/src/client/transport/do-rpc-client.test.ts +411 -0
- package/src/client/transport/do-rpc-client.ts +175 -51
- package/src/client/transport/http-rpc-client.ts +66 -45
- package/src/client/transport/rpc-client-error.test.ts +41 -0
- package/src/client/transport/rpc-client-error.ts +9 -0
- package/src/client/transport/ws-rpc-client.ts +40 -32
- package/src/common/do-rpc-schema.ts +13 -3
- package/src/common/http-rpc-schema.ts +5 -5
- package/src/common/mod.ts +4 -4
- package/src/common/sync-message-types.ts +16 -16
- package/src/common/ws-rpc-schema.ts +4 -4
|
@@ -1,18 +1,24 @@
|
|
|
1
|
-
import { SyncBackend, UnknownError } from '@livestore/common'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
|
|
6
|
+
Duration,
|
|
7
7
|
Effect,
|
|
8
8
|
identity,
|
|
9
9
|
Layer,
|
|
10
|
-
|
|
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
|
-
|
|
31
|
+
type PushBatchItem = SyncMessage.PushRequest['batch'][number]
|
|
26
32
|
|
|
27
|
-
|
|
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
|
-
/**
|
|
33
|
-
|
|
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
|
-
({
|
|
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) =>
|
|
71
|
+
callRpc: (payload) => getSyncBackendStub().rpc(payload),
|
|
56
72
|
callerContext: durableObjectContext,
|
|
57
|
-
})
|
|
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
|
|
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
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
92
|
-
}).pipe(Stream.
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
)
|
|
166
|
+
})(batch).pipe(Effect.mapError((cause) => new UnknownError({ cause })))
|
|
124
167
|
|
|
125
|
-
for (const
|
|
126
|
-
|
|
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' ||
|
|
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
|
|
183
|
+
const ping: SyncBackend.SyncBackend<{ createdAt: string }>['ping'] = rpcClient['SyncDoRpc.Ping']({
|
|
138
184
|
storeId,
|
|
139
185
|
payload,
|
|
140
|
-
}).pipe(
|
|
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:
|
|
171
|
-
*
|
|
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:
|
|
230
|
+
export const handleSyncUpdateRpc = (ctx: CfTypes.DurableObjectState, payload: Uint8Array<ArrayBuffer>) =>
|
|
177
231
|
Effect.gen(function* () {
|
|
178
|
-
const
|
|
179
|
-
const
|
|
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
|
|
240
|
+
const pullStreamQueue = pullRoutingFor(ctx).get(decodedPayload.requestId)
|
|
182
241
|
|
|
183
|
-
if (
|
|
242
|
+
if (pullStreamQueue === undefined) {
|
|
184
243
|
// Case: DO was hibernated, so we need to manually update the store
|
|
185
|
-
yield* Effect.log(`No
|
|
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*
|
|
247
|
+
yield* Queue.offerAll(pullStreamQueue, decoded)
|
|
189
248
|
}
|
|
190
|
-
}).pipe(
|
|
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.
|
|
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 {
|
|
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.
|
|
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.
|
|
59
|
+
requestTimeout?: Duration.Input
|
|
56
60
|
/**
|
|
57
61
|
* How often to send ping requests
|
|
58
62
|
* @default 10 seconds
|
|
59
63
|
*/
|
|
60
|
-
requestInterval?: Duration.
|
|
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.
|
|
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:
|
|
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
|
|
108
|
+
yield* rpcClient['SyncHttpRpc.Ping']({ storeId, payload })
|
|
104
109
|
|
|
105
110
|
yield* SubscriptionRef.set(isConnected, true)
|
|
106
111
|
}).pipe(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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.
|
|
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.
|
|
157
|
+
return Stream.paginate(initialPhase2Cursor, (currentCursor) =>
|
|
149
158
|
Effect.gen(function* () {
|
|
150
159
|
yield* Effect.sleep(livePullInterval)
|
|
151
160
|
|
|
152
|
-
const items = yield* rpcClient
|
|
153
|
-
|
|
154
|
-
|
|
161
|
+
const items = yield* rpcClient['SyncHttpRpc.Pull']({
|
|
162
|
+
storeId,
|
|
163
|
+
payload,
|
|
164
|
+
cursor: currentCursor,
|
|
165
|
+
}).pipe(Stream.runCollect)
|
|
155
166
|
|
|
156
|
-
const nextCursor =
|
|
157
|
-
Option.flatMap((item) => Option.
|
|
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
|
-
|
|
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 === '
|
|
172
|
-
? cause
|
|
173
|
-
:
|
|
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*
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
199
|
-
)
|
|
217
|
+
})(batch).pipe(Effect.mapError((cause) => new UnknownError({ cause })))
|
|
200
218
|
|
|
201
|
-
for (const
|
|
202
|
-
|
|
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 === '
|
|
209
|
-
? cause
|
|
210
|
-
:
|
|
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 })
|