@livestore/sync-cf 0.5.0-dev.0 → 0.5.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 +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 +34 -30
- package/dist/cf-worker/do/durable-object.js.map +1 -1
- package/dist/cf-worker/do/layer.d.ts.map +1 -1
- package/dist/cf-worker/do/layer.js +2 -2
- 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.map +1 -1
- package/dist/cf-worker/do/pull.js +3 -3
- package/dist/cf-worker/do/push.d.ts +2 -2
- package/dist/cf-worker/do/push.d.ts.map +1 -1
- package/dist/cf-worker/do/push.js +7 -6
- package/dist/cf-worker/do/push.js.map +1 -1
- package/dist/cf-worker/do/push.test.js +1 -1
- package/dist/cf-worker/do/sqlite.js +1 -1
- package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
- package/dist/cf-worker/do/sync-storage.js +3 -3
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts +15 -0
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.js +23 -7
- 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.js +6 -6
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +8 -1
- 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 -24
- 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 +10 -5
- package/dist/cf-worker/shared.d.ts.map +1 -1
- package/dist/cf-worker/shared.js +2 -2
- package/dist/cf-worker/shared.js.map +1 -1
- package/dist/cf-worker/worker.d.ts +3 -3
- package/dist/cf-worker/worker.d.ts.map +1 -1
- package/dist/cf-worker/worker.js +2 -2
- 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 +6 -3
- package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/do-rpc-client.js +65 -20
- 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.map +1 -1
- package/dist/client/transport/http-rpc-client.js +20 -10
- 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.map +1 -1
- package/dist/client/transport/ws-rpc-client.js +18 -12
- package/dist/client/transport/ws-rpc-client.js.map +1 -1
- package/dist/common/do-rpc-schema.d.ts +24 -11
- package/dist/common/do-rpc-schema.d.ts.map +1 -1
- package/dist/common/do-rpc-schema.js +10 -1
- package/dist/common/do-rpc-schema.js.map +1 -1
- package/dist/common/http-rpc-schema.d.ts +8 -8
- package/dist/common/http-rpc-schema.js +1 -1
- package/dist/common/mod.d.ts +1 -0
- package/dist/common/mod.d.ts.map +1 -1
- package/dist/common/mod.js +4 -3
- package/dist/common/mod.js.map +1 -1
- package/dist/common/sync-message-types.d.ts.map +1 -1
- package/dist/common/ws-rpc-schema.d.ts +1 -1
- package/dist/common/ws-rpc-schema.js +1 -1
- package/package.json +41 -41
- package/src/cf-worker/do/durable-object.ts +42 -45
- package/src/cf-worker/do/observability.test.ts +337 -0
- package/src/cf-worker/do/observability.ts +121 -0
- package/src/cf-worker/do/push.ts +3 -2
- package/src/cf-worker/do/transport/do-rpc-server.test.ts +32 -0
- package/src/cf-worker/do/transport/do-rpc-server.ts +23 -1
- package/src/cf-worker/do/transport/ws-rpc-server.ts +45 -21
- package/src/cf-worker/shared.ts +13 -6
- package/src/cf-worker/worker.ts +1 -1
- package/src/client/transport/do-rpc-client.test.ts +411 -0
- package/src/client/transport/do-rpc-client.ts +100 -18
- package/src/client/transport/http-rpc-client.ts +20 -9
- 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 +15 -10
- package/src/common/do-rpc-schema.ts +10 -0
- package/src/common/mod.ts +1 -0
package/src/cf-worker/shared.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { UnknownError } from '@livestore/common'
|
|
2
2
|
import type { CfTypes } from '@livestore/common-cf'
|
|
3
|
-
import type { Effect } from '@livestore/utils/effect'
|
|
3
|
+
import type { Effect, Layer } from '@livestore/utils/effect'
|
|
4
4
|
import { Result, Schema } from '@livestore/utils/effect'
|
|
5
5
|
|
|
6
6
|
import type { SearchParams } from '../common/mod.ts'
|
|
@@ -107,12 +107,19 @@ export type MakeDurableObjectClassOptions = {
|
|
|
107
107
|
responseHeaders?: Record<string, string>
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
serviceName?: string
|
|
113
|
-
}
|
|
110
|
+
/** Optional telemetry. Omit to avoid creating an exporter or sending telemetry. */
|
|
111
|
+
otel?: SyncBackendOtelOptions
|
|
114
112
|
}
|
|
115
113
|
|
|
114
|
+
export type SyncBackendOtelOptions =
|
|
115
|
+
/** Application-supplied tracer layer; LiveStore builds and finalizes its resources per sync operation. */
|
|
116
|
+
| Layer.Layer<never>
|
|
117
|
+
| {
|
|
118
|
+
/** Convenience OTLP/HTTP JSON exporter owned by LiveStore for each operation. */
|
|
119
|
+
baseUrl?: string
|
|
120
|
+
serviceName?: string
|
|
121
|
+
}
|
|
122
|
+
|
|
116
123
|
export type StoreId = string
|
|
117
124
|
export type DurableObjectId = string
|
|
118
125
|
|
|
@@ -146,7 +153,7 @@ const SearchParamsFromUrlSearchParams = Schema.fromURLSearchParams(SearchParamsS
|
|
|
146
153
|
*/
|
|
147
154
|
export const matchSyncRequest = (request: CfTypes.Request): SearchParams | undefined => {
|
|
148
155
|
const url = new URL(request.url)
|
|
149
|
-
const paramsResult = Schema.
|
|
156
|
+
const paramsResult = Schema.decodeResult(SearchParamsFromUrlSearchParams)(url.searchParams)
|
|
150
157
|
|
|
151
158
|
if (Result.isFailure(paramsResult) === true) {
|
|
152
159
|
return undefined
|
package/src/cf-worker/worker.ts
CHANGED
|
@@ -200,7 +200,7 @@ export const handleSyncRequest = <
|
|
|
200
200
|
// Always decode with the supplied schema when present, even if payload is undefined.
|
|
201
201
|
// This ensures required payloads are enforced by the schema.
|
|
202
202
|
if (syncPayloadSchema !== undefined) {
|
|
203
|
-
const decodedResult = Schema.
|
|
203
|
+
const decodedResult = Schema.decodeResult(syncPayloadSchema)(payload)
|
|
204
204
|
if (Result.isFailure(decodedResult) === true) {
|
|
205
205
|
const message = decodedResult.failure.toString()
|
|
206
206
|
console.error('Invalid payload (decode failed)', message)
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { BackendIdMismatchError, IsOfflineError, ServerAheadError, SyncBackend } from '@livestore/common'
|
|
2
|
+
import { type CfTypes, toDurableObjectHandler } from '@livestore/common-cf'
|
|
3
|
+
import { EventSequenceNumber } from '@livestore/common/schema'
|
|
4
|
+
import { Vitest } from '@livestore/utils-dev/node-vitest'
|
|
5
|
+
import {
|
|
6
|
+
Cause,
|
|
7
|
+
Clock,
|
|
8
|
+
Deferred,
|
|
9
|
+
Duration,
|
|
10
|
+
Effect,
|
|
11
|
+
Exit,
|
|
12
|
+
FetchHttpClient,
|
|
13
|
+
Fiber,
|
|
14
|
+
KeyValueStore,
|
|
15
|
+
Layer,
|
|
16
|
+
Option,
|
|
17
|
+
Stream,
|
|
18
|
+
TestClock,
|
|
19
|
+
} from '@livestore/utils/effect'
|
|
20
|
+
|
|
21
|
+
import { SyncDoRpc } from '../../common/do-rpc-schema.ts'
|
|
22
|
+
import { makeDoRpcSync, type SyncBackendRpcStub } from './do-rpc-client.ts'
|
|
23
|
+
|
|
24
|
+
Vitest.describe('DO-RPC sync client transport failures', () => {
|
|
25
|
+
Vitest.effect('a retryable rejection fails the pull as offline after a backoff', () =>
|
|
26
|
+
Effect.gen(function* () {
|
|
27
|
+
const { backend } = yield* makeBackend([rejectWith(cloudflareError({ retryable: true }))])
|
|
28
|
+
|
|
29
|
+
const { exit, delays } = yield* runRecordingBackoff(backend.pull(Option.none()).pipe(Stream.runCollect))
|
|
30
|
+
|
|
31
|
+
Vitest.expect(failureOf(exit)).toBeInstanceOf(IsOfflineError)
|
|
32
|
+
expectBackoff(delays[0]!, 1_000)
|
|
33
|
+
}),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
Vitest.effect('a retryable push rejection fails as offline', () =>
|
|
37
|
+
Effect.gen(function* () {
|
|
38
|
+
const { backend } = yield* makeBackend([rejectWith(cloudflareError({ retryable: true }))])
|
|
39
|
+
|
|
40
|
+
const { exit } = yield* runRecordingBackoff(backend.push([event]))
|
|
41
|
+
|
|
42
|
+
Vitest.expect(failureOf(exit)).toBeInstanceOf(IsOfflineError)
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
Vitest.effect('a retryable ping rejection fails as offline', () =>
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
const { backend } = yield* makeBackend([rejectWith(cloudflareError({ retryable: true }))])
|
|
49
|
+
|
|
50
|
+
const { exit } = yield* runRecordingBackoff(backend.ping)
|
|
51
|
+
|
|
52
|
+
Vitest.expect(failureOf(exit)).toBeInstanceOf(IsOfflineError)
|
|
53
|
+
}),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
Vitest.describe('keeps terminal failures terminal', () => {
|
|
57
|
+
// Overload wins over `retryable`, and `remote` alone does not mean retryable (infrastructure errors can carry it).
|
|
58
|
+
const terminal = [
|
|
59
|
+
{ name: 'overloaded and retryable', flags: { overloaded: true, retryable: true } },
|
|
60
|
+
{ name: 'remote only', flags: { remote: true } },
|
|
61
|
+
{ name: 'unflagged', flags: {} },
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
for (const { name, flags } of terminal) {
|
|
65
|
+
Vitest.effect(`${name}: pull dies with the original error without waiting`, () =>
|
|
66
|
+
Effect.gen(function* () {
|
|
67
|
+
const rejected = cloudflareError(flags)
|
|
68
|
+
const { backend } = yield* makeBackend([rejectWith(rejected)])
|
|
69
|
+
|
|
70
|
+
const { exit, delays } = yield* runRecordingBackoff(backend.pull(Option.none()).pipe(Stream.runCollect))
|
|
71
|
+
|
|
72
|
+
Vitest.expect(defectOf(exit)).toBe(rejected)
|
|
73
|
+
Vitest.expect(delays).toEqual([])
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
Vitest.effect(`${name}: push dies with the original error`, () =>
|
|
78
|
+
Effect.gen(function* () {
|
|
79
|
+
const rejected = cloudflareError(flags)
|
|
80
|
+
const { backend } = yield* makeBackend([rejectWith(rejected)])
|
|
81
|
+
|
|
82
|
+
const { exit } = yield* runRecordingBackoff(backend.push([event]))
|
|
83
|
+
|
|
84
|
+
Vitest.expect(defectOf(exit)).toBe(rejected)
|
|
85
|
+
}),
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
Vitest.describe('passes application errors through unchanged', () => {
|
|
91
|
+
Vitest.effect('pull: BackendIdMismatchError', () =>
|
|
92
|
+
Effect.gen(function* () {
|
|
93
|
+
const { backend } = yield* makeBackend([
|
|
94
|
+
serve({ pull: () => Stream.fail(new BackendIdMismatchError({ expected: 'a', received: 'b' })) }),
|
|
95
|
+
])
|
|
96
|
+
|
|
97
|
+
const { exit } = yield* runRecordingBackoff(backend.pull(Option.none()).pipe(Stream.runCollect))
|
|
98
|
+
|
|
99
|
+
Vitest.expect(failureOf(exit)).toBeInstanceOf(BackendIdMismatchError)
|
|
100
|
+
}),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
Vitest.effect('push: ServerAheadError', () =>
|
|
104
|
+
Effect.gen(function* () {
|
|
105
|
+
const { backend } = yield* makeBackend([
|
|
106
|
+
serve({
|
|
107
|
+
push: () =>
|
|
108
|
+
Effect.fail(
|
|
109
|
+
new ServerAheadError({
|
|
110
|
+
minimumExpectedNum: EventSequenceNumber.Global.make(2),
|
|
111
|
+
providedNum: EventSequenceNumber.Global.make(1),
|
|
112
|
+
}),
|
|
113
|
+
),
|
|
114
|
+
}),
|
|
115
|
+
])
|
|
116
|
+
|
|
117
|
+
const { exit } = yield* runRecordingBackoff(backend.push([event]))
|
|
118
|
+
|
|
119
|
+
Vitest.expect(failureOf(exit)).toBeInstanceOf(ServerAheadError)
|
|
120
|
+
}),
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
Vitest.describe('backoff', () => {
|
|
125
|
+
Vitest.effect('grows to the cap across pulls that each deliver a page and then fail', () =>
|
|
126
|
+
Effect.gen(function* () {
|
|
127
|
+
const failure = pageThenRejectWith(cloudflareError({ retryable: true }))
|
|
128
|
+
const expectedDelays = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000]
|
|
129
|
+
const { backend } = yield* makeBackend(expectedDelays.map(() => failure))
|
|
130
|
+
|
|
131
|
+
for (const expectedDelay of expectedDelays) {
|
|
132
|
+
const { exit, delays } = yield* runRecordingBackoff(backend.pull(Option.none()).pipe(Stream.runCollect))
|
|
133
|
+
Vitest.expect(failureOf(exit)).toBeInstanceOf(IsOfflineError)
|
|
134
|
+
Vitest.expect(delays).toHaveLength(1)
|
|
135
|
+
expectBackoff(delays[0]!, expectedDelay)
|
|
136
|
+
}
|
|
137
|
+
}),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
Vitest.effect('interrupts a pull while it is waiting to retry', () =>
|
|
141
|
+
Effect.gen(function* () {
|
|
142
|
+
const { backend } = yield* makeBackend([rejectWith(cloudflareError({ retryable: true }))])
|
|
143
|
+
const clock = yield* Clock.Clock
|
|
144
|
+
const waiting = yield* Deferred.make<void>()
|
|
145
|
+
const fiber = yield* backend.pull(Option.none()).pipe(
|
|
146
|
+
Stream.runCollect,
|
|
147
|
+
Effect.provideService(Clock.Clock, {
|
|
148
|
+
...clock,
|
|
149
|
+
sleep: (duration) => {
|
|
150
|
+
if (Duration.toMillis(duration) >= 60_000) return clock.sleep(duration)
|
|
151
|
+
return Deferred.succeed(waiting, undefined).pipe(Effect.andThen(clock.sleep(duration)))
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
Effect.forkChild,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
yield* Deferred.await(waiting)
|
|
158
|
+
Vitest.expect(fiber.pollUnsafe()).toBeUndefined()
|
|
159
|
+
yield* Fiber.interrupt(fiber)
|
|
160
|
+
Vitest.expect(Exit.hasInterrupts(yield* Fiber.await(fiber))).toBe(true)
|
|
161
|
+
}),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
Vitest.effect('resets once a pull completes its catch-up', () =>
|
|
165
|
+
Effect.gen(function* () {
|
|
166
|
+
const failure = rejectWith(cloudflareError({ retryable: true }))
|
|
167
|
+
const { backend } = yield* makeBackend([
|
|
168
|
+
failure,
|
|
169
|
+
failure,
|
|
170
|
+
serve({ pull: () => Stream.make(pullResponse(SyncBackend.pageInfoNoMore)) }),
|
|
171
|
+
failure,
|
|
172
|
+
])
|
|
173
|
+
const pull = runRecordingBackoff(backend.pull(Option.none()).pipe(Stream.runCollect))
|
|
174
|
+
|
|
175
|
+
yield* pull
|
|
176
|
+
yield* pull
|
|
177
|
+
const completed = yield* pull
|
|
178
|
+
const afterReset = yield* pull
|
|
179
|
+
|
|
180
|
+
Vitest.expect(Exit.isSuccess(completed.exit)).toBe(true)
|
|
181
|
+
expectBackoff(afterReset.delays[0]!, 1_000)
|
|
182
|
+
}),
|
|
183
|
+
)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
Vitest.effect('recovers on a fresh stub after a stub breaks', () =>
|
|
187
|
+
Effect.gen(function* () {
|
|
188
|
+
const broken = rejectWith(cloudflareError({ retryable: true }))
|
|
189
|
+
const healthy = serve({ pull: () => Stream.make(pullResponse(SyncBackend.pageInfoNoMore)) })
|
|
190
|
+
const { backend } = yield* makeBackend([broken, healthy])
|
|
191
|
+
|
|
192
|
+
const first = yield* runRecordingBackoff(backend.pull(Option.none()).pipe(Stream.runCollect))
|
|
193
|
+
const second = yield* runRecordingBackoff(backend.pull(Option.none()).pipe(Stream.runCollect))
|
|
194
|
+
|
|
195
|
+
Vitest.expect(failureOf(first.exit)).toBeInstanceOf(IsOfflineError)
|
|
196
|
+
Vitest.expect(Exit.isSuccess(second.exit)).toBe(true)
|
|
197
|
+
}),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
Vitest.effect('a stalled catch-up releases its producer and recovers on a fresh stub', () =>
|
|
201
|
+
Effect.gen(function* () {
|
|
202
|
+
const pageSeen = yield* Deferred.make<void>()
|
|
203
|
+
const cancelled = yield* Deferred.make<void>()
|
|
204
|
+
const { backend } = yield* makeBackend([
|
|
205
|
+
serve({
|
|
206
|
+
pull: () =>
|
|
207
|
+
Stream.make(pullResponse(SyncBackend.pageInfoMoreUnknown)).pipe(
|
|
208
|
+
Stream.concat(Stream.never),
|
|
209
|
+
Stream.ensuring(Deferred.succeed(cancelled, undefined)),
|
|
210
|
+
),
|
|
211
|
+
}),
|
|
212
|
+
serve({ pull: () => Stream.make(pullResponse(SyncBackend.pageInfoNoMore)) }),
|
|
213
|
+
])
|
|
214
|
+
const fiber = yield* backend.pull(Option.none()).pipe(
|
|
215
|
+
Stream.tap(() => Deferred.succeed(pageSeen, undefined)),
|
|
216
|
+
Stream.runCollect,
|
|
217
|
+
Effect.exit,
|
|
218
|
+
Effect.forkChild,
|
|
219
|
+
)
|
|
220
|
+
yield* Deferred.await(pageSeen)
|
|
221
|
+
yield* TestClock.adjust('62 seconds')
|
|
222
|
+
Vitest.expect(failureOf(yield* Fiber.join(fiber))).toBeInstanceOf(IsOfflineError)
|
|
223
|
+
yield* Deferred.await(cancelled)
|
|
224
|
+
const recovered = yield* backend.pull(Option.none()).pipe(Stream.runCollect)
|
|
225
|
+
Vitest.expect(recovered).toHaveLength(1)
|
|
226
|
+
}),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
Vitest.effect('a live pull can remain idle after catch-up without reconnecting', () =>
|
|
230
|
+
Effect.gen(function* () {
|
|
231
|
+
const caughtUp = yield* Deferred.make<void>()
|
|
232
|
+
const healthy = serve({ pull: () => Stream.make(pullResponse(SyncBackend.pageInfoNoMore)) })
|
|
233
|
+
const { backend } = yield* makeBackend([healthy, healthy]) // second call is unsubscribe on interruption
|
|
234
|
+
const fiber = yield* backend.pull(Option.none(), { live: true }).pipe(
|
|
235
|
+
Stream.tap(() => Deferred.succeed(caughtUp, undefined)),
|
|
236
|
+
Stream.runDrain,
|
|
237
|
+
Effect.forkChild,
|
|
238
|
+
)
|
|
239
|
+
yield* Deferred.await(caughtUp)
|
|
240
|
+
yield* TestClock.adjust('2 minutes')
|
|
241
|
+
Vitest.expect(fiber.pollUnsafe()).toBeUndefined()
|
|
242
|
+
yield* Fiber.interrupt(fiber)
|
|
243
|
+
}),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
Vitest.effect('the catch-up deadline restarts for each page', () =>
|
|
247
|
+
Effect.gen(function* () {
|
|
248
|
+
const sendFirst = yield* Deferred.make<void>()
|
|
249
|
+
const sendLast = yield* Deferred.make<void>()
|
|
250
|
+
const firstSeen = yield* Deferred.make<void>()
|
|
251
|
+
const { backend } = yield* makeBackend([
|
|
252
|
+
serve({
|
|
253
|
+
pull: () =>
|
|
254
|
+
Stream.fromEffect(Deferred.await(sendFirst)).pipe(
|
|
255
|
+
Stream.map(() => pullResponse(SyncBackend.pageInfoMoreUnknown)),
|
|
256
|
+
Stream.concat(
|
|
257
|
+
Stream.fromEffect(Deferred.await(sendLast)).pipe(
|
|
258
|
+
Stream.map(() => pullResponse(SyncBackend.pageInfoNoMore)),
|
|
259
|
+
),
|
|
260
|
+
),
|
|
261
|
+
),
|
|
262
|
+
}),
|
|
263
|
+
])
|
|
264
|
+
const fiber = yield* backend.pull(Option.none()).pipe(
|
|
265
|
+
Stream.tap(() => Deferred.succeed(firstSeen, undefined)),
|
|
266
|
+
Stream.runCollect,
|
|
267
|
+
Effect.forkChild,
|
|
268
|
+
)
|
|
269
|
+
yield* TestClock.adjust('40 seconds')
|
|
270
|
+
yield* Deferred.succeed(sendFirst, undefined)
|
|
271
|
+
yield* Deferred.await(firstSeen)
|
|
272
|
+
yield* TestClock.adjust('40 seconds')
|
|
273
|
+
yield* Deferred.succeed(sendLast, undefined)
|
|
274
|
+
Vitest.expect(yield* Fiber.join(fiber)).toHaveLength(2)
|
|
275
|
+
}),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
Vitest.effect('overload on a stream read remains terminal', () =>
|
|
279
|
+
Effect.gen(function* () {
|
|
280
|
+
const error = cloudflareError({ overloaded: true, retryable: true })
|
|
281
|
+
const { backend } = yield* makeBackend([pageThenRejectWith(error)])
|
|
282
|
+
const { exit, delays } = yield* runRecordingBackoff(backend.pull(Option.none()).pipe(Stream.runCollect))
|
|
283
|
+
Vitest.expect(defectOf(exit)).toBe(error)
|
|
284
|
+
Vitest.expect(delays).toEqual([])
|
|
285
|
+
}),
|
|
286
|
+
)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
type CallRpc = (payload: Uint8Array) => Promise<Uint8Array | CfTypes.ReadableStream>
|
|
290
|
+
|
|
291
|
+
const backendId = 'backend'
|
|
292
|
+
|
|
293
|
+
const event = {
|
|
294
|
+
name: 'todoCreated',
|
|
295
|
+
args: {},
|
|
296
|
+
seqNum: EventSequenceNumber.Global.make(1),
|
|
297
|
+
parentSeqNum: EventSequenceNumber.Global.make(0),
|
|
298
|
+
clientId: 'client',
|
|
299
|
+
sessionId: 'session',
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const pullResponse = (pageInfo: SyncBackend.PullResPageInfo) => ({
|
|
303
|
+
rpcRequestId: '0',
|
|
304
|
+
batch: [{ eventEncoded: event, metadata: Option.none() }],
|
|
305
|
+
pageInfo,
|
|
306
|
+
backendId,
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
/** A Cloudflare DO error carries runtime flags as own properties on the thrown `Error`. */
|
|
310
|
+
const cloudflareError = (flags: { retryable?: boolean; overloaded?: boolean; remote?: boolean }) =>
|
|
311
|
+
Object.assign(new Error('Network connection lost.'), flags)
|
|
312
|
+
|
|
313
|
+
const rejectWith =
|
|
314
|
+
(error: Error): CallRpc =>
|
|
315
|
+
() =>
|
|
316
|
+
Promise.reject(error)
|
|
317
|
+
|
|
318
|
+
/** Serves the real handler, so each call carries encoded responses exactly as a backend DO sends them. */
|
|
319
|
+
const serve =
|
|
320
|
+
(handlers: {
|
|
321
|
+
pull?: () => Stream.Stream<ReturnType<typeof pullResponse>, BackendIdMismatchError>
|
|
322
|
+
push?: () => Effect.Effect<{}, ServerAheadError>
|
|
323
|
+
}): CallRpc =>
|
|
324
|
+
(payload) =>
|
|
325
|
+
toDurableObjectHandler(SyncDoRpc, {
|
|
326
|
+
layer: SyncDoRpc.toLayer({
|
|
327
|
+
'SyncDoRpc.Pull': () => handlers.pull?.() ?? Stream.empty,
|
|
328
|
+
'SyncDoRpc.Push': () => handlers.push?.() ?? Effect.succeed({}),
|
|
329
|
+
'SyncDoRpc.Ping': () => Effect.void,
|
|
330
|
+
'SyncDoRpc.Unsubscribe': () => Effect.void,
|
|
331
|
+
}),
|
|
332
|
+
})(new Uint8Array(payload)).pipe(Effect.runPromise)
|
|
333
|
+
|
|
334
|
+
/** Delivers one page, then rejects the next stream read with `error`. */
|
|
335
|
+
const pageThenRejectWith =
|
|
336
|
+
(error: Error): CallRpc =>
|
|
337
|
+
async (payload) => {
|
|
338
|
+
const response = await serve({
|
|
339
|
+
pull: () => Stream.make(pullResponse(SyncBackend.pageInfoMoreUnknown)),
|
|
340
|
+
})(payload)
|
|
341
|
+
if (response instanceof Uint8Array) return response
|
|
342
|
+
const source = response.getReader()
|
|
343
|
+
// Keep only the page frame; replace the normal RPC exit with a transport failure.
|
|
344
|
+
const { value } = await source.read()
|
|
345
|
+
await source.cancel()
|
|
346
|
+
source.releaseLock()
|
|
347
|
+
let sentPage = false
|
|
348
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
349
|
+
pull(controller) {
|
|
350
|
+
if (sentPage === true) return controller.error(error)
|
|
351
|
+
sentPage = true
|
|
352
|
+
controller.enqueue(value)
|
|
353
|
+
},
|
|
354
|
+
})
|
|
355
|
+
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- bridge platform ReadableStream to the CF type
|
|
356
|
+
return stream as unknown as CfTypes.ReadableStream
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Each supplied RPC implementation belongs to one stub, and stays attached if that stub is reused. */
|
|
360
|
+
const makeBackend = (stubs: ReadonlyArray<CallRpc>) =>
|
|
361
|
+
Effect.gen(function* () {
|
|
362
|
+
let nextStub = 0
|
|
363
|
+
const backend = yield* makeDoRpcSync({
|
|
364
|
+
getSyncBackendStub: () => {
|
|
365
|
+
const rpc = stubs[nextStub++]
|
|
366
|
+
if (rpc === undefined) throw new Error('Unexpected extra stub')
|
|
367
|
+
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- a test double only needs `rpc`
|
|
368
|
+
return { rpc } as unknown as SyncBackendRpcStub
|
|
369
|
+
},
|
|
370
|
+
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- only live pulls read the DO state
|
|
371
|
+
durableObjectState: {} as CfTypes.DurableObjectState,
|
|
372
|
+
durableObjectContext: { bindingName: 'CLIENT_DO', durableObjectId: 'client-do' },
|
|
373
|
+
})({ storeId: 'store', clientId: 'client', payload: undefined }).pipe(
|
|
374
|
+
Effect.provide(Layer.mergeAll(FetchHttpClient.layer, KeyValueStore.layerMemory)),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
return { backend }
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
/** Observe the waits requested by production code without polling fibers or waiting on wall-clock timers. */
|
|
381
|
+
const runRecordingBackoff = <A, E>(effect: Effect.Effect<A, E>) =>
|
|
382
|
+
Effect.gen(function* () {
|
|
383
|
+
const clock = yield* Clock.Clock
|
|
384
|
+
const delays: number[] = []
|
|
385
|
+
const exit = yield* effect.pipe(
|
|
386
|
+
Effect.provideService(Clock.Clock, {
|
|
387
|
+
...clock,
|
|
388
|
+
sleep: (duration) => {
|
|
389
|
+
if (Duration.toMillis(duration) >= 60_000) return clock.sleep(duration)
|
|
390
|
+
return Effect.sync(() => {
|
|
391
|
+
delays.push(Duration.toMillis(duration))
|
|
392
|
+
})
|
|
393
|
+
},
|
|
394
|
+
}),
|
|
395
|
+
Effect.exit,
|
|
396
|
+
)
|
|
397
|
+
return { exit, delays }
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
const expectBackoff = (delay: number, baseMs: number) => {
|
|
401
|
+
Vitest.expect(delay).toBeGreaterThanOrEqual(baseMs * 0.8)
|
|
402
|
+
Vitest.expect(delay).toBeLessThanOrEqual(baseMs * 1.2)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const failureOf = (exit: Exit.Exit<unknown, unknown>): unknown =>
|
|
406
|
+
Exit.isFailure(exit) === true ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined
|
|
407
|
+
|
|
408
|
+
const defectOf = (exit: Exit.Exit<unknown, unknown>): unknown =>
|
|
409
|
+
Exit.isFailure(exit) === true && Option.isNone(Cause.findErrorOption(exit.cause)) === true
|
|
410
|
+
? Cause.squash(exit.cause)
|
|
411
|
+
: undefined
|
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import { SyncBackend, UnknownError } from '@livestore/common'
|
|
2
|
-
import { type CfTypes, layerProtocolDurableObject } from '@livestore/common-cf'
|
|
1
|
+
import { IsOfflineError, SyncBackend, UnknownError } from '@livestore/common'
|
|
2
|
+
import { type CfTypes, DoRpcReadError, layerProtocolDurableObject } from '@livestore/common-cf'
|
|
3
3
|
import { splitArrayBySize } from '@livestore/common/sync'
|
|
4
4
|
import { shouldNeverHappen } from '@livestore/utils'
|
|
5
5
|
import {
|
|
6
|
+
Duration,
|
|
6
7
|
Effect,
|
|
7
8
|
identity,
|
|
8
9
|
Layer,
|
|
10
|
+
Match,
|
|
9
11
|
Option,
|
|
12
|
+
Predicate,
|
|
10
13
|
Queue,
|
|
14
|
+
Random,
|
|
11
15
|
ReadonlyArray as EffectArray,
|
|
16
|
+
Ref,
|
|
12
17
|
RpcClient,
|
|
13
18
|
RpcSerialization,
|
|
14
19
|
Schema,
|
|
@@ -28,8 +33,11 @@ type PushBatchItem = SyncMessage.PushRequest['batch'][number]
|
|
|
28
33
|
export interface SyncBackendRpcStub extends CfTypes.DurableObjectStub, SyncBackendRpcInterface {}
|
|
29
34
|
|
|
30
35
|
export interface DoRpcSyncOptions {
|
|
31
|
-
/**
|
|
32
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Returns a stub for the sync backend Durable Object. Called for every RPC call, because Cloudflare leaves a stub
|
|
38
|
+
* broken after many exceptions, so a retry needs a fresh one.
|
|
39
|
+
*/
|
|
40
|
+
getSyncBackendStub: () => SyncBackendRpcStub
|
|
33
41
|
/**
|
|
34
42
|
* State handle of the client DurableObject running this sync backend. Scopes live-pull routing to
|
|
35
43
|
* this instance so it resets when the DO is reconstructed (see {@link handleSyncUpdateRpc}).
|
|
@@ -51,7 +59,7 @@ export interface DoRpcSyncOptions {
|
|
|
51
59
|
*/
|
|
52
60
|
export const makeDoRpcSync =
|
|
53
61
|
({
|
|
54
|
-
|
|
62
|
+
getSyncBackendStub,
|
|
55
63
|
durableObjectState,
|
|
56
64
|
durableObjectContext,
|
|
57
65
|
}: DoRpcSyncOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
|
|
@@ -60,14 +68,16 @@ export const makeDoRpcSync =
|
|
|
60
68
|
const isConnected = yield* SubscriptionRef.make(true)
|
|
61
69
|
|
|
62
70
|
const ProtocolLive = layerProtocolDurableObject({
|
|
63
|
-
callRpc: (payload) =>
|
|
71
|
+
callRpc: (payload) => getSyncBackendStub().rpc(payload),
|
|
64
72
|
callerContext: durableObjectContext,
|
|
65
|
-
})
|
|
73
|
+
})
|
|
66
74
|
|
|
67
75
|
const context = yield* Layer.build(ProtocolLive)
|
|
68
76
|
|
|
69
77
|
const rpcClient = yield* RpcClient.make(SyncDoRpc).pipe(Effect.provide(context))
|
|
70
78
|
|
|
79
|
+
const pullBackoff = yield* makePullBackoff
|
|
80
|
+
|
|
71
81
|
// Nothing to do here
|
|
72
82
|
const connect = Effect.void
|
|
73
83
|
|
|
@@ -84,6 +94,12 @@ export const makeDoRpcSync =
|
|
|
84
94
|
storeId,
|
|
85
95
|
rpcContext: options?.live === true ? { callerContext: durableObjectContext } : undefined,
|
|
86
96
|
}).pipe(
|
|
97
|
+
// Catch-up must make progress. Live callbacks below may legitimately remain idle indefinitely.
|
|
98
|
+
Stream.timeoutOrElse({
|
|
99
|
+
duration: '60 seconds',
|
|
100
|
+
orElse: () =>
|
|
101
|
+
Stream.fail(new IsOfflineError({ cause: new Error('DO-RPC pull made no progress for 60 seconds') })),
|
|
102
|
+
}),
|
|
87
103
|
options?.live === true
|
|
88
104
|
? Stream.concatWithLastElement((res) =>
|
|
89
105
|
Effect.gen(function* () {
|
|
@@ -100,14 +116,28 @@ export const makeDoRpcSync =
|
|
|
100
116
|
|
|
101
117
|
routing.set(requestId, queue)
|
|
102
118
|
|
|
119
|
+
// Graceful shutdown only (eviction runs no finalizers); matched on requestId so a late send can't drop a newer pull's row
|
|
120
|
+
yield* Effect.addFinalizer(() =>
|
|
121
|
+
rpcClient['SyncDoRpc.Unsubscribe']({
|
|
122
|
+
storeId,
|
|
123
|
+
durableObjectId: durableObjectContext.durableObjectId,
|
|
124
|
+
requestId,
|
|
125
|
+
}).pipe(Effect.timeout('5 seconds'), Effect.tapCauseLogPretty, Effect.ignore),
|
|
126
|
+
)
|
|
127
|
+
|
|
103
128
|
return Stream.fromQueue(queue)
|
|
104
129
|
}).pipe(Stream.unwrap),
|
|
105
130
|
)
|
|
106
131
|
: identity,
|
|
132
|
+
Stream.catchReason('RpcClientError', 'RpcClientDefect', (reason) =>
|
|
133
|
+
Stream.fromEffect(classifyTransportFailure(reason.cause)),
|
|
134
|
+
),
|
|
135
|
+
Stream.tapError((error) => (error._tag === 'IsOfflineError' ? pullBackoff.wait : Effect.void)),
|
|
136
|
+
Stream.tap((res) => (res.pageInfo._tag === 'NoMore' ? pullBackoff.reset : Effect.void)),
|
|
107
137
|
Stream.tap((res) => backendIdHelper.lazySet(res.backendId)),
|
|
108
138
|
Stream.map((res) => Struct.omit(res, ['backendId'])),
|
|
109
139
|
Stream.mapError((cause) =>
|
|
110
|
-
cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
|
|
140
|
+
cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError' || cause._tag === 'IsOfflineError'
|
|
111
141
|
? cause
|
|
112
142
|
: new UnknownError({ cause }),
|
|
113
143
|
),
|
|
@@ -139,8 +169,12 @@ export const makeDoRpcSync =
|
|
|
139
169
|
yield* rpcClient['SyncDoRpc.Push']({ batch: batchChunk, storeId, backendId })
|
|
140
170
|
}
|
|
141
171
|
},
|
|
172
|
+
Effect.catchReason('RpcClientError', 'RpcClientDefect', (reason) => classifyTransportFailure(reason.cause)),
|
|
142
173
|
Effect.mapError((cause) =>
|
|
143
|
-
cause._tag === 'UnknownError' ||
|
|
174
|
+
cause._tag === 'UnknownError' ||
|
|
175
|
+
cause._tag === 'ServerAheadError' ||
|
|
176
|
+
cause._tag === 'BackendIdMismatchError' ||
|
|
177
|
+
cause._tag === 'IsOfflineError'
|
|
144
178
|
? cause
|
|
145
179
|
: new UnknownError({ cause }),
|
|
146
180
|
),
|
|
@@ -149,7 +183,11 @@ export const makeDoRpcSync =
|
|
|
149
183
|
const ping: SyncBackend.SyncBackend<{ createdAt: string }>['ping'] = rpcClient['SyncDoRpc.Ping']({
|
|
150
184
|
storeId,
|
|
151
185
|
payload,
|
|
152
|
-
}).pipe(
|
|
186
|
+
}).pipe(
|
|
187
|
+
Effect.catchReason('RpcClientError', 'RpcClientDefect', (reason) => classifyTransportFailure(reason.cause)),
|
|
188
|
+
Effect.mapError((cause) => (cause._tag === 'IsOfflineError' ? cause : new UnknownError({ cause }))),
|
|
189
|
+
Effect.withSpan('rpc-sync-client:ping'),
|
|
190
|
+
)
|
|
153
191
|
|
|
154
192
|
return SyncBackend.of({
|
|
155
193
|
connect,
|
|
@@ -191,12 +229,12 @@ export const makeDoRpcSync =
|
|
|
191
229
|
*/
|
|
192
230
|
export const handleSyncUpdateRpc = (ctx: CfTypes.DurableObjectState, payload: Uint8Array<ArrayBuffer>) =>
|
|
193
231
|
Effect.gen(function* () {
|
|
194
|
-
const
|
|
195
|
-
const
|
|
196
|
-
const [response] =
|
|
232
|
+
const serialization = yield* RpcSerialization.RpcSerialization
|
|
233
|
+
const parser = serialization.makeUnsafe()
|
|
234
|
+
const [response] = parser.decode(payload)
|
|
197
235
|
const decodedPayload = yield* Schema.decodeUnknownEffect(ResponseChunkEncoded)(response)
|
|
198
|
-
const decoded = yield* Schema.
|
|
199
|
-
decodedPayload.values
|
|
236
|
+
const decoded = yield* Schema.decodeEffect(serialization.codecFor(Schema.NonEmptyArray(SyncMessage.PullResponse)))(
|
|
237
|
+
decodedPayload.values,
|
|
200
238
|
)
|
|
201
239
|
|
|
202
240
|
const pullStreamQueue = pullRoutingFor(ctx).get(decodedPayload.requestId)
|
|
@@ -206,13 +244,18 @@ export const handleSyncUpdateRpc = (ctx: CfTypes.DurableObjectState, payload: Ui
|
|
|
206
244
|
yield* Effect.log(`No pull stream queue found for ${decodedPayload.requestId}`)
|
|
207
245
|
} else {
|
|
208
246
|
// Case: DO was still alive, so the existing `pull` will pick up the new events
|
|
209
|
-
yield* Queue.
|
|
247
|
+
yield* Queue.offerAll(pullStreamQueue, decoded)
|
|
210
248
|
}
|
|
211
|
-
}).pipe(
|
|
249
|
+
}).pipe(
|
|
250
|
+
Effect.provide(RpcSerialization.layerSchemaBinary()),
|
|
251
|
+
Effect.withSpan('rpc-sync-client:rpcCallback'),
|
|
252
|
+
Effect.tapCauseLogPretty,
|
|
253
|
+
Effect.runPromise,
|
|
254
|
+
)
|
|
212
255
|
|
|
213
256
|
const ResponseChunkEncoded = Schema.Struct({
|
|
214
257
|
requestId: Schema.String,
|
|
215
|
-
values: Schema.
|
|
258
|
+
values: Schema.Uint8Array,
|
|
216
259
|
})
|
|
217
260
|
|
|
218
261
|
type EffectRpcRequestId = string // 0, 1, 2, ...
|
|
@@ -235,3 +278,42 @@ const pullRoutingFor = (ctx: CfTypes.DurableObjectState): PullRouting => {
|
|
|
235
278
|
pullRoutingByInstance.set(ctx, routing)
|
|
236
279
|
return routing
|
|
237
280
|
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Calls require Cloudflare's retryable flag. Transferred streams can lose those flags, so unflagged read
|
|
284
|
+
* failures are also offline (application failures arrive as encoded RPC exits). Explicit overload and
|
|
285
|
+
* non-retryable remote errors stay terminal.
|
|
286
|
+
* https://developers.cloudflare.com/durable-objects/best-practices/error-handling/
|
|
287
|
+
*/
|
|
288
|
+
const classifyTransportFailure = (failure: unknown) => {
|
|
289
|
+
const cause = failure instanceof DoRpcReadError ? failure.cause : failure
|
|
290
|
+
return Match.value({
|
|
291
|
+
retryable:
|
|
292
|
+
hasTrueFlag(cause, 'retryable') || (failure instanceof DoRpcReadError && hasTrueFlag(cause, 'remote') === false),
|
|
293
|
+
overloaded: hasTrueFlag(cause, 'overloaded'),
|
|
294
|
+
}).pipe(
|
|
295
|
+
Match.when({ overloaded: true }, () => Effect.die(cause)),
|
|
296
|
+
Match.when({ retryable: true }, () => Effect.fail(new IsOfflineError({ cause }))),
|
|
297
|
+
Match.orElse(() => Effect.die(cause)),
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const hasTrueFlag = (cause: unknown, flag: 'retryable' | 'overloaded' | 'remote') =>
|
|
302
|
+
Predicate.hasProperty(cause, flag) === true && cause[flag] === true
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* This transport delays retryable failures before surfacing them, independently of the leader's connectivity gate.
|
|
306
|
+
* The attempt count spans pull calls and resets only when a pull completes its catch-up, so a pull that delivers a
|
|
307
|
+
* page and then fails does not reset it.
|
|
308
|
+
*/
|
|
309
|
+
const makePullBackoff = Effect.gen(function* () {
|
|
310
|
+
const attempt = yield* Ref.make(0)
|
|
311
|
+
return {
|
|
312
|
+
wait: Effect.gen(function* () {
|
|
313
|
+
const n = yield* Ref.getAndUpdate(attempt, (n) => n + 1)
|
|
314
|
+
const jitter = yield* Random.nextBetween(0.8, 1.2)
|
|
315
|
+
yield* Effect.sleep(Duration.millis(Math.min(1_000 * 2 ** n, 30_000) * jitter))
|
|
316
|
+
}),
|
|
317
|
+
reset: Ref.set(attempt, 0),
|
|
318
|
+
}
|
|
319
|
+
})
|