@livestore/sync-cf 0.4.0 → 0.5.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +5 -5
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
  4. package/dist/cf-worker/do/durable-object.js +53 -36
  5. package/dist/cf-worker/do/durable-object.js.map +1 -1
  6. package/dist/cf-worker/do/layer.d.ts +22 -18
  7. package/dist/cf-worker/do/layer.d.ts.map +1 -1
  8. package/dist/cf-worker/do/layer.js +45 -35
  9. package/dist/cf-worker/do/layer.js.map +1 -1
  10. package/dist/cf-worker/do/observability.d.ts +23 -0
  11. package/dist/cf-worker/do/observability.d.ts.map +1 -0
  12. package/dist/cf-worker/do/observability.js +52 -0
  13. package/dist/cf-worker/do/observability.js.map +1 -0
  14. package/dist/cf-worker/do/observability.test.d.ts +2 -0
  15. package/dist/cf-worker/do/observability.test.d.ts.map +1 -0
  16. package/dist/cf-worker/do/observability.test.js +204 -0
  17. package/dist/cf-worker/do/observability.test.js.map +1 -0
  18. package/dist/cf-worker/do/pull.d.ts +3 -3
  19. package/dist/cf-worker/do/pull.d.ts.map +1 -1
  20. package/dist/cf-worker/do/pull.js +19 -20
  21. package/dist/cf-worker/do/pull.js.map +1 -1
  22. package/dist/cf-worker/do/push.d.ts +16 -3
  23. package/dist/cf-worker/do/push.d.ts.map +1 -1
  24. package/dist/cf-worker/do/push.js +74 -53
  25. package/dist/cf-worker/do/push.js.map +1 -1
  26. package/dist/cf-worker/do/push.test.d.ts +2 -0
  27. package/dist/cf-worker/do/push.test.d.ts.map +1 -0
  28. package/dist/cf-worker/do/push.test.js +30 -0
  29. package/dist/cf-worker/do/push.test.js.map +1 -0
  30. package/dist/cf-worker/do/sqlite.d.ts +34 -46
  31. package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
  32. package/dist/cf-worker/do/sqlite.js +2 -2
  33. package/dist/cf-worker/do/sqlite.js.map +1 -1
  34. package/dist/cf-worker/do/sync-storage.d.ts +7 -6
  35. package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
  36. package/dist/cf-worker/do/sync-storage.js +29 -17
  37. package/dist/cf-worker/do/sync-storage.js.map +1 -1
  38. package/dist/cf-worker/do/transport/do-rpc-server.d.ts +17 -2
  39. package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
  40. package/dist/cf-worker/do/transport/do-rpc-server.js +41 -23
  41. package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
  42. package/dist/cf-worker/do/transport/do-rpc-server.test.d.ts +2 -0
  43. package/dist/cf-worker/do/transport/do-rpc-server.test.d.ts.map +1 -0
  44. package/dist/cf-worker/do/transport/do-rpc-server.test.js +28 -0
  45. package/dist/cf-worker/do/transport/do-rpc-server.test.js.map +1 -0
  46. package/dist/cf-worker/do/transport/http-rpc-server.d.ts +2 -2
  47. package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
  48. package/dist/cf-worker/do/transport/http-rpc-server.js +12 -12
  49. package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
  50. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +9 -2
  51. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
  52. package/dist/cf-worker/do/transport/ws-rpc-server.js +37 -29
  53. package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
  54. package/dist/cf-worker/mod.js +3 -3
  55. package/dist/cf-worker/shared.d.ts +37 -29
  56. package/dist/cf-worker/shared.d.ts.map +1 -1
  57. package/dist/cf-worker/shared.js +14 -12
  58. package/dist/cf-worker/shared.js.map +1 -1
  59. package/dist/cf-worker/worker.d.ts +9 -9
  60. package/dist/cf-worker/worker.d.ts.map +1 -1
  61. package/dist/cf-worker/worker.js +17 -15
  62. package/dist/cf-worker/worker.js.map +1 -1
  63. package/dist/client/mod.js +3 -3
  64. package/dist/client/transport/do-rpc-client.d.ts +18 -6
  65. package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
  66. package/dist/client/transport/do-rpc-client.js +112 -38
  67. package/dist/client/transport/do-rpc-client.js.map +1 -1
  68. package/dist/client/transport/do-rpc-client.test.d.ts +2 -0
  69. package/dist/client/transport/do-rpc-client.test.d.ts.map +1 -0
  70. package/dist/client/transport/do-rpc-client.test.js +268 -0
  71. package/dist/client/transport/do-rpc-client.test.js.map +1 -0
  72. package/dist/client/transport/http-rpc-client.d.ts +3 -3
  73. package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
  74. package/dist/client/transport/http-rpc-client.js +48 -29
  75. package/dist/client/transport/http-rpc-client.js.map +1 -1
  76. package/dist/client/transport/rpc-client-error.d.ts +5 -0
  77. package/dist/client/transport/rpc-client-error.d.ts.map +1 -0
  78. package/dist/client/transport/rpc-client-error.js +8 -0
  79. package/dist/client/transport/rpc-client-error.js.map +1 -0
  80. package/dist/client/transport/rpc-client-error.test.d.ts +2 -0
  81. package/dist/client/transport/rpc-client-error.test.d.ts.map +1 -0
  82. package/dist/client/transport/rpc-client-error.test.js +33 -0
  83. package/dist/client/transport/rpc-client-error.test.js.map +1 -0
  84. package/dist/client/transport/ws-rpc-client.d.ts +3 -3
  85. package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
  86. package/dist/client/transport/ws-rpc-client.js +33 -23
  87. package/dist/client/transport/ws-rpc-client.js.map +1 -1
  88. package/dist/common/do-rpc-schema.d.ts +53 -41
  89. package/dist/common/do-rpc-schema.d.ts.map +1 -1
  90. package/dist/common/do-rpc-schema.js +13 -4
  91. package/dist/common/do-rpc-schema.js.map +1 -1
  92. package/dist/common/http-rpc-schema.d.ts +38 -39
  93. package/dist/common/http-rpc-schema.d.ts.map +1 -1
  94. package/dist/common/http-rpc-schema.js +6 -6
  95. package/dist/common/http-rpc-schema.js.map +1 -1
  96. package/dist/common/mod.d.ts +5 -5
  97. package/dist/common/mod.d.ts.map +1 -1
  98. package/dist/common/mod.js +7 -7
  99. package/dist/common/mod.js.map +1 -1
  100. package/dist/common/sync-message-types.d.ts +116 -133
  101. package/dist/common/sync-message-types.d.ts.map +1 -1
  102. package/dist/common/sync-message-types.js +26 -14
  103. package/dist/common/sync-message-types.js.map +1 -1
  104. package/dist/common/ws-rpc-schema.d.ts +33 -34
  105. package/dist/common/ws-rpc-schema.d.ts.map +1 -1
  106. package/dist/common/ws-rpc-schema.js +5 -5
  107. package/dist/common/ws-rpc-schema.js.map +1 -1
  108. package/package.json +42 -58
  109. package/src/cf-worker/do/durable-object.ts +70 -64
  110. package/src/cf-worker/do/layer.ts +142 -99
  111. package/src/cf-worker/do/observability.test.ts +337 -0
  112. package/src/cf-worker/do/observability.ts +121 -0
  113. package/src/cf-worker/do/pull.ts +28 -28
  114. package/src/cf-worker/do/push.test.ts +40 -0
  115. package/src/cf-worker/do/push.ts +139 -115
  116. package/src/cf-worker/do/sqlite.ts +1 -1
  117. package/src/cf-worker/do/sync-storage.ts +34 -21
  118. package/src/cf-worker/do/transport/do-rpc-server.test.ts +32 -0
  119. package/src/cf-worker/do/transport/do-rpc-server.ts +46 -35
  120. package/src/cf-worker/do/transport/http-rpc-server.ts +7 -10
  121. package/src/cf-worker/do/transport/ws-rpc-server.ts +52 -28
  122. package/src/cf-worker/shared.ts +29 -17
  123. package/src/cf-worker/worker.ts +21 -22
  124. package/src/client/transport/do-rpc-client.test.ts +411 -0
  125. package/src/client/transport/do-rpc-client.ts +175 -51
  126. package/src/client/transport/http-rpc-client.ts +66 -45
  127. package/src/client/transport/rpc-client-error.test.ts +41 -0
  128. package/src/client/transport/rpc-client-error.ts +9 -0
  129. package/src/client/transport/ws-rpc-client.ts +40 -32
  130. package/src/common/do-rpc-schema.ts +13 -3
  131. package/src/common/http-rpc-schema.ts +5 -5
  132. package/src/common/mod.ts +4 -4
  133. package/src/common/sync-message-types.ts +16 -16
  134. package/src/common/ws-rpc-schema.ts +4 -4
@@ -0,0 +1,337 @@
1
+ import { ROOT_CONTEXT, type SpanStatus, SpanStatusCode, trace } from '@opentelemetry/api'
2
+
3
+ import { BackendIdMismatchError, UnknownError } from '@livestore/common'
4
+ import { type CfTypes, layerProtocolDurableObject, WsContext } from '@livestore/common-cf'
5
+ import { makeNoopSpan, makeNoopTracer } from '@livestore/utils'
6
+ import { Vitest } from '@livestore/utils-dev/node-vitest'
7
+ import {
8
+ Deferred,
9
+ Effect,
10
+ FetchHttpClient,
11
+ Fiber,
12
+ Layer,
13
+ Option,
14
+ OtelTracer,
15
+ RpcClient,
16
+ RpcTest,
17
+ Schema,
18
+ Stream,
19
+ TestClock,
20
+ Tracer,
21
+ } from '@livestore/utils/effect'
22
+
23
+ import { SyncDoRpc } from '../../common/do-rpc-schema.ts'
24
+ import { SyncWsRpc } from '../../common/ws-rpc-schema.ts'
25
+ import { WebSocketAttachmentSchema } from '../shared.ts'
26
+ import type * as DoCtx from './layer.ts'
27
+ import { makeObservability, rpcSpanOptions, withRpcSpan, withRpcStreamSpan } from './observability.ts'
28
+ import { createDoRpcHandler } from './transport/do-rpc-server.ts'
29
+ import { makeRpcHandlers } from './transport/ws-rpc-server.ts'
30
+
31
+ Vitest.describe('sync-cf telemetry ownership', () => {
32
+ Vitest.afterEach(() => Vitest.vi.restoreAllMocks())
33
+
34
+ Vitest.effect('runs sync effects and streams without exporting when telemetry is omitted', () =>
35
+ Effect.gen(function* () {
36
+ const fetch: typeof globalThis.fetch = Vitest.vi.fn(async () => new Response('{}'))
37
+ const observability = makeObservability(undefined)
38
+ const result = yield* Effect.succeed('ack').pipe(
39
+ Effect.withSpan('push'),
40
+ observability.effect,
41
+ Effect.provideService(FetchHttpClient.Fetch, fetch),
42
+ )
43
+ const history = yield* Stream.make('event').pipe(
44
+ Stream.withSpan('pull'),
45
+ observability.stream,
46
+ Stream.runCollect,
47
+ Effect.provideService(FetchHttpClient.Fetch, fetch),
48
+ )
49
+
50
+ yield* TestClock.adjust('4 seconds')
51
+ Vitest.expect(result).toBe('ack')
52
+ Vitest.expect(history).toEqual(['event'])
53
+ Vitest.expect(fetch).not.toHaveBeenCalled()
54
+ }),
55
+ )
56
+
57
+ Vitest.effect('builds and finalizes a supplied layer independently for overlapping operations', () =>
58
+ Effect.gen(function* () {
59
+ const firstStarted = yield* Deferred.make<void>()
60
+ const secondStarted = yield* Deferred.make<void>()
61
+ const firstGate = yield* Deferred.make<void>()
62
+ const secondGate = yield* Deferred.make<void>()
63
+ const firstReleased = yield* Deferred.make<void>()
64
+ const secondReleased = yield* Deferred.make<void>()
65
+ const builds: string[] = []
66
+ const releases: string[] = []
67
+ const layer = Layer.effectDiscard(
68
+ Effect.acquireRelease(
69
+ Effect.currentSpan.pipe(
70
+ Effect.orDie,
71
+ Effect.tap((span) => Effect.sync(() => builds.push(span.name))),
72
+ ),
73
+ (span) =>
74
+ Effect.sync(() => releases.push(span.name)).pipe(
75
+ Effect.andThen(Deferred.succeed(span.name === 'first' ? firstReleased : secondReleased, undefined)),
76
+ ),
77
+ ),
78
+ )
79
+ const observability = makeObservability(layer)
80
+ const first = yield* Deferred.succeed(firstStarted, undefined).pipe(
81
+ Effect.andThen(Deferred.await(firstGate)),
82
+ observability.effect,
83
+ Effect.withSpan('first'),
84
+ Effect.forkChild,
85
+ )
86
+ yield* Deferred.await(firstStarted)
87
+ const second = yield* Deferred.succeed(secondStarted, undefined).pipe(
88
+ Effect.andThen(Deferred.await(secondGate)),
89
+ observability.effect,
90
+ Effect.withSpan('second'),
91
+ Effect.forkChild,
92
+ )
93
+ yield* Deferred.await(secondStarted)
94
+ Vitest.expect(builds).toEqual(['first', 'second'])
95
+ Vitest.expect(releases).toEqual([])
96
+
97
+ yield* Deferred.succeed(firstGate, undefined)
98
+ yield* Fiber.join(first)
99
+ yield* Deferred.await(firstReleased)
100
+ Vitest.expect(releases).toEqual(['first'])
101
+ Vitest.expect(second.pollUnsafe()).toBeUndefined()
102
+
103
+ yield* Deferred.succeed(secondGate, undefined)
104
+ yield* Fiber.join(second)
105
+ yield* Deferred.await(secondReleased)
106
+ Vitest.expect(releases).toEqual(['first', 'second'])
107
+ }),
108
+ )
109
+
110
+ Vitest.effect('finalizes finite history while the live subscription is still open', () =>
111
+ Effect.gen(function* () {
112
+ const { layer, ended } = makeRecordingTracer()
113
+ const finalized = yield* Deferred.make<ReadonlyArray<string>>()
114
+ const live = yield* Deferred.make<void>()
115
+ const observability = makeObservability(
116
+ Layer.mergeAll(layer, Layer.effectDiscard(Effect.addFinalizer(() => Deferred.succeed(finalized, [...ended])))),
117
+ )
118
+ const fiber = yield* Stream.make('history').pipe(
119
+ Stream.withSpan('pull-history'),
120
+ observability.stream,
121
+ Stream.concat(Stream.fromEffect(Deferred.succeed(live, undefined)).pipe(Stream.concat(Stream.never))),
122
+ Stream.runDrain,
123
+ Effect.forkChild,
124
+ )
125
+ yield* Effect.addFinalizer(() => Fiber.interrupt(fiber))
126
+ yield* Deferred.await(live)
127
+ Vitest.expect(yield* Deferred.await(finalized)).toContain('pull-history')
128
+ Vitest.expect(fiber.pollUnsafe()).toBeUndefined()
129
+ }),
130
+ )
131
+
132
+ Vitest.effect('ends WebSocket telemetry before the production handler enters its live tail', () =>
133
+ Effect.gen(function* () {
134
+ const { layer, ended } = makeRecordingTracer()
135
+ const finalized = yield* Deferred.make<void>()
136
+ const observability = makeObservability(
137
+ Layer.mergeAll(layer, Layer.effectDiscard(Effect.addFinalizer(() => Deferred.succeed(finalized, undefined)))),
138
+ )
139
+ const input = makeDoInput()
140
+ const handlers = makeRpcHandlers({ ...input, observability }).pipe(
141
+ Layer.provide(Layer.succeed(WsContext, WsContext.of({ ws: makeWebSocket() }))),
142
+ )
143
+ const client = yield* RpcTest.makeClient(SyncWsRpc).pipe(Effect.provide(handlers))
144
+ const pullFiber = yield* client['SyncWsRpc.Pull']({
145
+ storeId: 'store',
146
+ cursor: Option.none(),
147
+ live: true,
148
+ }).pipe(Stream.runDrain, Effect.forkChild)
149
+ yield* Effect.addFinalizer(() => Fiber.interrupt(pullFiber))
150
+
151
+ yield* Deferred.await(finalized).pipe(Effect.timeout('1 second'))
152
+ Vitest.expect(ended).toContain('RpcServer.SyncWsRpc.Pull')
153
+ Vitest.expect(pullFiber.pollUnsafe()).toBeUndefined()
154
+ }),
155
+ )
156
+
157
+ Vitest.effect('installs telemetry inside the Durable Object RPC stream runtime', () =>
158
+ Effect.gen(function* () {
159
+ const { layer, ended } = makeRecordingTracer()
160
+ const observability = makeObservability(layer)
161
+ const input = makeDoInput()
162
+ const protocol = layerProtocolDurableObject({
163
+ callerContext: { bindingName: 'TEST', durableObjectId: 'client' },
164
+ callRpc: (payload) =>
165
+ createDoRpcHandler({
166
+ // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Effect RPC owns this ArrayBuffer-backed payload.
167
+ payload: payload as Uint8Array<ArrayBuffer>,
168
+ input,
169
+ observability,
170
+ }).pipe(Effect.runPromise),
171
+ })
172
+ const responses = yield* Effect.gen(function* () {
173
+ const client = yield* RpcClient.make(SyncDoRpc)
174
+ return yield* client['SyncDoRpc.Pull']({ storeId: 'store', cursor: Option.none() }).pipe(Stream.runCollect)
175
+ }).pipe(Effect.provide(protocol))
176
+
177
+ Vitest.expect(responses).toHaveLength(1)
178
+ Vitest.expect(ended).toContain('RpcServer.SyncDoRpc.Pull')
179
+ }),
180
+ )
181
+
182
+ Vitest.effect('attaches a finite span to a sampled caller across an unexported RPC envelope', () =>
183
+ Effect.gen(function* () {
184
+ const { layer, spans, ended } = makeRecordingTracer()
185
+ const observability = makeObservability(layer)
186
+ const caller = Tracer.externalSpan({ traceId: '1'.repeat(32), spanId: '2222222222222222', sampled: true })
187
+ yield* Effect.flatMap(rpcSpanOptions, (options) =>
188
+ withRpcSpan(Effect.void.pipe(Effect.withSpan('push')), 'finite-rpc', options),
189
+ ).pipe(observability.effect, Effect.withSpan('unexported-rpc-envelope'), Effect.withParentSpan(caller))
190
+
191
+ Vitest.expect(spans.get('finite-rpc')?.parentId).toBe(caller.spanId)
192
+ Vitest.expect(spans.get('push')?.parentId).toBe(spans.get('finite-rpc')?.spanId)
193
+ Vitest.expect(ended).toEqual(['push', 'finite-rpc'])
194
+ }),
195
+ )
196
+
197
+ Vitest.effect('starts a backend root when the RPC caller is unsampled', () =>
198
+ Effect.gen(function* () {
199
+ const { layer, spans, ended } = makeRecordingTracer()
200
+ const observability = makeObservability(layer)
201
+ const caller = Tracer.externalSpan({ traceId: '1'.repeat(32), spanId: '2222222222222222', sampled: false })
202
+ yield* Effect.flatMap(rpcSpanOptions, (options) => withRpcSpan(Effect.void, 'finite-rpc', options)).pipe(
203
+ observability.effect,
204
+ Effect.withSpan('unexported-rpc-envelope'),
205
+ Effect.withParentSpan(caller),
206
+ )
207
+
208
+ Vitest.expect(spans.get('finite-rpc')?.parentId).toBeUndefined()
209
+ Vitest.expect(ended).toEqual(['finite-rpc'])
210
+ }),
211
+ )
212
+
213
+ Vitest.effect('keeps anticipated RPC recovery failures out of boundary error telemetry', () =>
214
+ Effect.gen(function* () {
215
+ const { layer, statuses } = makeRecordingTracer()
216
+ const observability = makeObservability(layer)
217
+ const expected = new BackendIdMismatchError({ expected: 'backend-a', received: 'backend-b' })
218
+ const unexpected = new UnknownError({ cause: new Error('boom') })
219
+
220
+ const effectError = yield* withRpcSpan(Effect.fail(expected), 'expected-effect').pipe(
221
+ observability.effect,
222
+ Effect.flip,
223
+ )
224
+ const streamError = yield* Stream.fail(expected).pipe(
225
+ (_) => withRpcStreamSpan(_, 'expected-stream'),
226
+ observability.stream,
227
+ Stream.runDrain,
228
+ Effect.flip,
229
+ )
230
+ const unexpectedError = yield* withRpcSpan(Effect.fail(unexpected), 'unexpected-effect').pipe(
231
+ observability.effect,
232
+ Effect.flip,
233
+ )
234
+
235
+ Vitest.expect(effectError).toBe(expected)
236
+ Vitest.expect(streamError).toBe(expected)
237
+ Vitest.expect(unexpectedError).toBe(unexpected)
238
+ Vitest.expect(statuses.get('expected-effect')).toBe(SpanStatusCode.OK)
239
+ Vitest.expect(statuses.get('expected-stream')).toBe(SpanStatusCode.OK)
240
+ Vitest.expect(statuses.get('unexpected-effect')).toBe(SpanStatusCode.ERROR)
241
+ }),
242
+ )
243
+
244
+ Vitest.effect('forwards the operation exit to supplied layer finalizers', () =>
245
+ Effect.gen(function* () {
246
+ const released = yield* Deferred.make<string>()
247
+ const expected = new BackendIdMismatchError({ expected: 'backend-a', received: 'backend-b' })
248
+ const observability = makeObservability(
249
+ Layer.effectDiscard(
250
+ Effect.acquireRelease(Effect.void, (_, exit) => Deferred.succeed(released, exit._tag).pipe(Effect.asVoid)),
251
+ ),
252
+ )
253
+
254
+ const error = yield* Effect.fail(expected).pipe(observability.effect, Effect.flip)
255
+
256
+ Vitest.expect(error).toBe(expected)
257
+ Vitest.expect(yield* Deferred.await(released)).toBe('Failure')
258
+ }),
259
+ )
260
+
261
+ Vitest.live('acknowledges before the owned OTLP collector responds', () =>
262
+ Effect.gen(function* () {
263
+ const started = Promise.withResolvers<void>()
264
+ const gate = Promise.withResolvers<void>()
265
+ const acknowledged = yield* Deferred.make<void>()
266
+ const fetch: typeof globalThis.fetch = Vitest.vi.fn(() => {
267
+ started.resolve()
268
+ return gate.promise.then(() => new Response('{}', { headers: { 'content-type': 'application/json' } }))
269
+ })
270
+ yield* Effect.addFinalizer(() => Effect.sync(() => gate.resolve()))
271
+ const observability = makeObservability({ baseUrl: 'http://collector.invalid' })
272
+ yield* Effect.void.pipe(
273
+ Effect.withSpan('push'),
274
+ observability.effect,
275
+ Effect.andThen(Deferred.succeed(acknowledged, undefined)),
276
+ Effect.provideService(FetchHttpClient.Fetch, fetch),
277
+ Effect.forkChild,
278
+ )
279
+ yield* Effect.promise(() => started.promise)
280
+ yield* Effect.yieldNow
281
+ Vitest.expect(yield* Deferred.isDone(acknowledged)).toBe(true)
282
+ }),
283
+ )
284
+ })
285
+
286
+ /** Record the public OTel boundary; span IDs are compared relationally, not fixed to creation order. */
287
+ const makeRecordingTracer = () => {
288
+ const tracer = makeNoopTracer()
289
+ const ended: string[] = []
290
+ const statuses = new Map<string, SpanStatusCode>()
291
+ const spans = new Map<string, { spanId: string; parentId: string | undefined }>()
292
+ let nextId = 0
293
+ Vitest.vi.spyOn(tracer, 'startSpan').mockImplementation((name, _options, context) => {
294
+ const spanId = (++nextId).toString(16).padStart(16, '0')
295
+ spans.set(name, { spanId, parentId: trace.getSpanContext(context ?? ROOT_CONTEXT)?.spanId })
296
+ const noopSpan = makeNoopSpan()
297
+ return {
298
+ ...noopSpan,
299
+ spanContext: () => ({ traceId: '1'.repeat(32), spanId, traceFlags: 1 }),
300
+ setStatus: (status: SpanStatus) => {
301
+ statuses.set(name, status.code)
302
+ return noopSpan
303
+ },
304
+ end: () => {
305
+ ended.push(name)
306
+ },
307
+ }
308
+ })
309
+ const layer = OtelTracer.layerWithoutOtelTracer.pipe(Layer.provide(Layer.succeed(OtelTracer.OtelTracer, tracer)))
310
+ return { layer, ended, spans, statuses }
311
+ }
312
+
313
+ /** Minimal empty SQLite-backed Durable Object boundary for transport composition tests. */
314
+ const makeDoInput = (): Omit<DoCtx.DoCtxInput, 'from'> => {
315
+ const emptyCursor = Object.assign([], { toArray: () => [] })
316
+ const doSelf = {
317
+ env: {},
318
+ ctx: {
319
+ storage: {
320
+ sql: { exec: () => emptyCursor },
321
+ kv: { get: () => undefined, put: () => undefined, delete: () => undefined },
322
+ deleteAll: async () => undefined,
323
+ },
324
+ },
325
+ }
326
+
327
+ // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- The test intentionally stubs the narrow Worker APIs reached by an empty pull.
328
+ return { doSelf: doSelf as unknown as DoCtx.DoCtxInput['doSelf'], doOptions: undefined }
329
+ }
330
+
331
+ const makeWebSocket = (): CfTypes.WebSocket => {
332
+ const attachment = Schema.encodeSync(WebSocketAttachmentSchema)({ storeId: 'store', pullRequestIds: [] })
333
+ const ws = { deserializeAttachment: () => attachment, close: Vitest.vi.fn() }
334
+
335
+ // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- The handler only reads the attachment and closes invalid sockets.
336
+ return ws as unknown as CfTypes.WebSocket
337
+ }
@@ -0,0 +1,121 @@
1
+ import type { BackendIdMismatchError, ServerAheadError, UnknownError } from '@livestore/common'
2
+ import {
3
+ Effect,
4
+ FetchHttpClient,
5
+ Layer,
6
+ Option,
7
+ Otlp,
8
+ Ref,
9
+ Result,
10
+ Scope,
11
+ Stream,
12
+ type Tracer,
13
+ } from '@livestore/utils/effect'
14
+
15
+ import type { SyncBackendOtelOptions } from '../shared.ts'
16
+
17
+ /** Keep exporter ownership separate from sync scopes: their finalizers must not delay acknowledgments. */
18
+ export const makeObservability = (options: SyncBackendOtelOptions | undefined) => {
19
+ const layer = makeLayer(options)
20
+
21
+ const backgroundLayer = Layer.effectContext(
22
+ Effect.acquireRelease(Scope.make(), (scope, exit) => Scope.close(scope, exit).pipe(runInBackground)).pipe(
23
+ Effect.flatMap((scope) => Layer.buildWithScope(layer, scope)),
24
+ ),
25
+ )
26
+
27
+ return {
28
+ effect: <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
29
+ options === undefined ? effect : effect.pipe(Effect.provide(backgroundLayer, { local: true })),
30
+ stream: <A, E, R>(stream: Stream.Stream<A, E, R>) =>
31
+ options === undefined ? stream : stream.pipe(Stream.provide(backgroundLayer, { local: true })),
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Effect RPC's envelope lives until a live subscription closes and may never be exported.
37
+ * Give finite sync work its own exported boundary, attached to a sampled caller or a new backend root.
38
+ */
39
+ export const rpcSpanOptions = Effect.currentSpan.pipe(
40
+ Effect.map((span) => makeRpcSpanOptions(span.parent)),
41
+ Effect.catchTag('NoSuchElementError', () => Effect.succeed({ root: true })),
42
+ )
43
+
44
+ export const makeRpcSpanOptions = (parent: Option.Option<Tracer.AnySpan>): Tracer.SpanOptions => ({
45
+ // Client runtimes use an unsampled no-op span by default. Parenting to it would suppress backend-only telemetry.
46
+ parent: parent.pipe(
47
+ Option.filter((span) => span.sampled),
48
+ Option.getOrUndefined,
49
+ ),
50
+ root: true,
51
+ })
52
+
53
+ /** Keep expected sync-recovery results out of error telemetry while preserving the typed failure for RPC callers. */
54
+ export const withRpcSpan = <A, E extends SyncRpcError, R>(
55
+ effect: Effect.Effect<A, E, R>,
56
+ name: string,
57
+ options?: Tracer.SpanOptions,
58
+ ): Effect.Effect<A, E, R> =>
59
+ effect.pipe(
60
+ Effect.map(Result.succeed),
61
+ Effect.catchIf(isExpectedSyncError, (error) => Effect.succeed(Result.fail(error))),
62
+ Effect.withSpan(name, options),
63
+ Effect.flatMap(resultToEffect),
64
+ )
65
+
66
+ /** Stream equivalent of `withRpcSpan`; expected failures are restored after the boundary span ends successfully. */
67
+ export const withRpcStreamSpan = <A, E extends SyncRpcError, R>(
68
+ stream: Stream.Stream<A, E, R>,
69
+ name: string,
70
+ options?: Tracer.SpanOptions,
71
+ ): Stream.Stream<A, E, R> =>
72
+ Stream.unwrap(
73
+ Effect.gen(function* () {
74
+ const expectedError = yield* Ref.make(Option.none<E>())
75
+ const instrumented = stream.pipe(
76
+ Stream.catchIf(isExpectedSyncError, (error) =>
77
+ Stream.fromEffect(Ref.set(expectedError, Option.some(error))).pipe(Stream.drain),
78
+ ),
79
+ Stream.withSpan(name, options),
80
+ )
81
+ const restoreExpectedError = Stream.fromEffect(Ref.get(expectedError)).pipe(
82
+ Stream.flatMap(
83
+ Option.match({
84
+ onNone: () => Stream.empty,
85
+ onSome: Stream.fail,
86
+ }),
87
+ ),
88
+ )
89
+
90
+ return instrumented.pipe(Stream.concat(restoreExpectedError))
91
+ }),
92
+ )
93
+
94
+ type SyncRpcError = UnknownError | ServerAheadError | BackendIdMismatchError
95
+
96
+ const makeLayer = (options: SyncBackendOtelOptions | undefined): Layer.Layer<never> => {
97
+ if (isTracerLayer(options) === true) return options
98
+ if (options?.baseUrl !== undefined) {
99
+ return Otlp.layerJson({
100
+ baseUrl: options.baseUrl,
101
+ tracerExportInterval: 50,
102
+ shutdownTimeout: 3000,
103
+ resource: { serviceName: options.serviceName ?? 'sync-cf-do' },
104
+ }).pipe(Layer.provide(FetchHttpClient.layer))
105
+ }
106
+ return Layer.empty
107
+ }
108
+
109
+ /** DOs stay active for pending work; no shutdown hook or connection-wide timer is required. */
110
+ const runInBackground = (effect: Effect.Effect<void>) =>
111
+ effect.pipe(Effect.ignoreCause, Effect.withTracerEnabled(false), Effect.forkDetach, Effect.asVoid)
112
+
113
+ /** Preserve the known Layer requirements when narrowing the configuration union. */
114
+ const isTracerLayer = (options: SyncBackendOtelOptions | undefined): options is Layer.Layer<never> =>
115
+ Layer.isLayer(options)
116
+
117
+ const isExpectedSyncError = (error: SyncRpcError): boolean =>
118
+ error._tag === 'ServerAheadError' || error._tag === 'BackendIdMismatchError'
119
+
120
+ const resultToEffect = <A, E>(result: Result.Result<A, E>): Effect.Effect<A, E> =>
121
+ Result.isSuccess(result) === true ? Effect.succeed(result.success) : Effect.fail(result.failure)
@@ -1,11 +1,11 @@
1
1
  import { BackendIdMismatchError, SyncBackend, UnknownError } from '@livestore/common'
2
- import { splitChunkBySize } from '@livestore/common/sync'
3
- import { Chunk, Effect, Option, Schema, Stream } from '@livestore/utils/effect'
2
+ import { splitArrayBySize } from '@livestore/common/sync'
3
+ import { Effect, Option, Schema, Stream } from '@livestore/utils/effect'
4
4
 
5
5
  import { MAX_PULL_EVENTS_PER_MESSAGE, MAX_WS_MESSAGE_BYTES } from '../../common/constants.ts'
6
6
  import { SyncMessage } from '../../common/mod.ts'
7
7
  import type { ForwardedHeaders } from '../shared.ts'
8
- import { DoCtx } from './layer.ts'
8
+ import * as DoCtx from './layer.ts'
9
9
 
10
10
  const encodePullResponse = Schema.encodeSync(SyncMessage.PullResponse)
11
11
 
@@ -24,22 +24,20 @@ export const makeEndingPullStream = ({
24
24
  headers,
25
25
  }: {
26
26
  req: SyncMessage.PullRequest
27
- payload: Schema.JsonValue | undefined
27
+ payload: Schema.Json | undefined
28
28
  headers: ForwardedHeaders | undefined
29
- }): Stream.Stream<SyncMessage.PullResponse, UnknownError | BackendIdMismatchError, DoCtx> =>
29
+ }): Stream.Stream<SyncMessage.PullResponse, UnknownError | BackendIdMismatchError, DoCtx.DoCtx> =>
30
30
  Effect.gen(function* () {
31
- const { doOptions, backendId, storeId, storage } = yield* DoCtx
31
+ const { doOptions, backendId, storeId, storage } = yield* DoCtx.DoCtx
32
32
 
33
33
  if (doOptions?.onPull !== undefined) {
34
- yield* Effect.tryAll(() =>
34
+ yield* Effect.trySyncOrPromiseOrEffect(() =>
35
35
  doOptions.onPull!(req, {
36
36
  storeId,
37
37
  ...(payload !== undefined ? { payload } : {}),
38
38
  ...(headers !== undefined ? { headers } : {}),
39
39
  }),
40
- ).pipe(
41
- UnknownError.mapToUnknownError,
42
- )
40
+ ).pipe(UnknownError.mapToUnknownError)
43
41
  }
44
42
 
45
43
  if (req.cursor._tag === 'Some' && req.cursor.value.backendId !== backendId) {
@@ -51,8 +49,8 @@ export const makeEndingPullStream = ({
51
49
  )
52
50
 
53
51
  return storedEvents.pipe(
54
- Stream.mapChunksEffect(
55
- splitChunkBySize({
52
+ Stream.mapArrayEffect(
53
+ splitArrayBySize({
56
54
  maxItems: MAX_PULL_EVENTS_PER_MESSAGE,
57
55
  maxBytes: MAX_WS_MESSAGE_BYTES,
58
56
  encode: (batch) =>
@@ -61,23 +59,27 @@ export const makeEndingPullStream = ({
61
59
  ),
62
60
  }),
63
61
  ),
64
- Stream.mapAccum(total, (remaining, chunk) => {
65
- const asArray = Chunk.toReadonlyArray(chunk)
66
- const nextRemaining = Math.max(0, remaining - asArray.length)
62
+ Stream.mapAccum(
63
+ () => total,
64
+ (remaining, array) => {
65
+ const nextRemaining = Math.max(0, remaining - array.length)
67
66
 
68
- return [
69
- nextRemaining,
70
- SyncMessage.PullResponse.make({
71
- batch: asArray,
72
- pageInfo: nextRemaining > 0 ? SyncBackend.pageInfoMoreKnown(nextRemaining) : SyncBackend.pageInfoNoMore,
73
- backendId,
74
- }),
75
- ] as const
76
- }),
67
+ return [
68
+ nextRemaining,
69
+ [
70
+ SyncMessage.PullResponse.make({
71
+ batch: array,
72
+ pageInfo: nextRemaining > 0 ? SyncBackend.pageInfoMoreKnown(nextRemaining) : SyncBackend.pageInfoNoMore,
73
+ backendId,
74
+ }),
75
+ ],
76
+ ] as const
77
+ },
78
+ ),
77
79
  Stream.tap(
78
80
  Effect.fn(function* (res) {
79
81
  if (doOptions?.onPullRes !== undefined) {
80
- yield* Effect.tryAll(() => doOptions.onPullRes!(res)).pipe(UnknownError.mapToUnknownError)
82
+ yield* Effect.trySyncOrPromiseOrEffect(() => doOptions.onPullRes!(res)).pipe(UnknownError.mapToUnknownError)
81
83
  }
82
84
  }),
83
85
  ),
@@ -86,9 +88,7 @@ export const makeEndingPullStream = ({
86
88
  }).pipe(
87
89
  Stream.unwrap,
88
90
  Stream.mapError((cause) =>
89
- cause._tag === 'BackendIdMismatchError' || cause._tag === 'UnknownError'
90
- ? cause
91
- : new UnknownError({ cause }),
91
+ cause._tag === 'BackendIdMismatchError' || cause._tag === 'UnknownError' ? cause : new UnknownError({ cause }),
92
92
  ),
93
93
  Stream.withSpan('cloudflare-provider:pull'),
94
94
  )
@@ -0,0 +1,40 @@
1
+ import { Vitest } from '@livestore/utils-dev/node-vitest'
2
+ import { Deferred, Duration, Effect, Fiber, Semaphore } from '@livestore/utils/effect'
3
+
4
+ import { runSerializedPushAdmission } from './push.ts'
5
+
6
+ Vitest.describe('sync-cf push admission', () => {
7
+ Vitest.live('publishes an admitted push before honoring interruption', () =>
8
+ Effect.gen(function* () {
9
+ const semaphore = yield* Semaphore.make(1)
10
+ const persisted = yield* Deferred.make<void>()
11
+ const allowBroadcast = yield* Deferred.make<void>()
12
+ const broadcasted = yield* Deferred.make<void>()
13
+
14
+ const admissionFiber = yield* runSerializedPushAdmission(
15
+ semaphore,
16
+ Effect.gen(function* () {
17
+ yield* Deferred.succeed(persisted, undefined)
18
+ yield* Deferred.await(allowBroadcast)
19
+ yield* Deferred.succeed(broadcasted, undefined)
20
+ }),
21
+ ).pipe(Effect.forkChild)
22
+
23
+ // Reproduce cancellation after persistence but before the pull broadcast handoff.
24
+ yield* Deferred.await(persisted)
25
+ const interruptStarted = yield* Deferred.make<void>()
26
+ const interruptFiber = yield* Effect.gen(function* () {
27
+ yield* Deferred.succeed(interruptStarted, undefined)
28
+ yield* Fiber.interrupt(admissionFiber)
29
+ }).pipe(Effect.forkChild)
30
+ yield* Deferred.await(interruptStarted)
31
+ yield* Effect.yieldNow
32
+
33
+ yield* Deferred.succeed(allowBroadcast, undefined)
34
+ yield* Deferred.await(broadcasted).pipe(Effect.timeout(Duration.seconds(1)))
35
+ yield* Fiber.join(interruptFiber)
36
+
37
+ Vitest.expect(yield* Deferred.isDone(broadcasted)).toBe(true)
38
+ }),
39
+ )
40
+ })