@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
@@ -1,53 +1,62 @@
1
1
  import { UnknownError } from '@livestore/common'
2
2
  import { type CfTypes, toDurableObjectHandler } from '@livestore/common-cf'
3
- import {
4
- Effect,
5
- Headers,
6
- HttpServer,
7
- Layer,
8
- Logger,
9
- LogLevel,
10
- Option,
11
- RpcSerialization,
12
- Stream,
13
- } from '@livestore/utils/effect'
3
+ import { Effect, Headers, Option, Stream } from '@livestore/utils/effect'
14
4
 
15
5
  import { SyncDoRpc } from '../../../common/do-rpc-schema.ts'
16
- import { SyncMessage } from '../../../common/mod.ts'
17
- import { DoCtx, type DoCtxInput } from '../layer.ts'
6
+ import { rpcSubscriptionKeyPrefix, type RpcSubscription } from '../../shared.ts'
7
+ import * as DoCtx from '../layer.ts'
8
+ import { makeRpcSpanOptions, type makeObservability, withRpcStreamSpan } from '../observability.ts'
18
9
  import { makeEndingPullStream } from '../pull.ts'
19
10
  import { makePush } from '../push.ts'
20
11
 
21
12
  export interface DoRpcHandlerOptions {
22
13
  payload: Uint8Array<ArrayBuffer>
23
- input: Omit<DoCtxInput, 'from'>
14
+ input: Omit<DoCtx.DoCtxInput, 'from'>
15
+ observability: ReturnType<typeof makeObservability>
16
+ }
17
+
18
+ /**
19
+ * Drops a live-pull subscription row only if it still belongs to the pull that asked. A replacement store booted on
20
+ * the same client DO while a slow shutdown is still draining overwrites the row with its own request id, and the
21
+ * earlier pull's late unsubscribe must not take that subscription down.
22
+ */
23
+ export const dropRpcSubscription = (
24
+ kv: { get: (key: string) => RpcSubscription | undefined; delete: (key: string) => unknown },
25
+ { durableObjectId, requestId }: { durableObjectId: string; requestId: string },
26
+ ): void => {
27
+ const key = `${rpcSubscriptionKeyPrefix}${durableObjectId}`
28
+ if (kv.get(key)?.requestId === requestId) {
29
+ kv.delete(key)
30
+ }
24
31
  }
25
32
 
26
33
  export const createDoRpcHandler = (
27
34
  options: DoRpcHandlerOptions,
28
35
  ): Effect.Effect<Uint8Array<ArrayBuffer> | CfTypes.ReadableStream> =>
29
- Effect.gen(this, function* () {
30
- const { payload, input } = options
31
- // const { rpcSubscriptions, backendId, doOptions, ctx, env } = yield* DoCtx
36
+ Effect.gen({ self: this }, function* () {
37
+ const { payload, input, observability } = options
38
+ const parent = yield* Effect.currentParentSpan.pipe(Effect.option)
32
39
 
33
40
  // TODO add admin RPCs
34
41
  const RpcLive = SyncDoRpc.toLayer({
35
- 'SyncDoRpc.Ping': (_req) => {
36
- return Effect.succeed(SyncMessage.Pong.make({}))
37
- },
42
+ 'SyncDoRpc.Ping': () => Effect.void,
43
+ 'SyncDoRpc.Unsubscribe': (req) => Effect.sync(() => dropRpcSubscription(input.doSelf.ctx.storage.kv, req)),
38
44
  'SyncDoRpc.Pull': (req, { headers }) =>
39
- Effect.gen(this, function* () {
40
- const { rpcSubscriptions } = yield* DoCtx
45
+ Effect.gen({ self: this }, function* () {
46
+ const { ctx } = yield* DoCtx.DoCtx
41
47
 
42
48
  // TODO rename `req.rpcContext` to something more appropriate
43
49
  if (req.rpcContext !== undefined) {
44
- rpcSubscriptions.set(req.storeId, {
50
+ const subscription: RpcSubscription = {
45
51
  storeId: req.storeId,
46
52
  subscribedAt: Date.now(),
47
53
  requestId: Headers.get(headers, 'x-rpc-request-id').pipe(Option.getOrThrow),
48
54
  callerContext: req.rpcContext.callerContext,
49
55
  ...(req.payload !== undefined ? { payload: req.payload } : {}),
50
- })
56
+ }
57
+ // Keyed by client DO id (not storeId — one backend serves many client DOs). The DO's synchronous KV
58
+ // storage is the single source of truth, so the entry outlives backend reconstruction.
59
+ ctx.storage.kv.put(`${rpcSubscriptionKeyPrefix}${subscription.callerContext.durableObjectId}`, subscription)
51
60
  }
52
61
 
53
62
  // DO-RPC doesn't have HTTP headers context - headers are undefined
@@ -58,25 +67,30 @@ export const createDoRpcHandler = (
58
67
  ...res,
59
68
  rpcRequestId: Headers.get(headers, 'x-rpc-request-id').pipe(Option.getOrThrow),
60
69
  })),
61
- Stream.provideLayer(DoCtx.Default({ ...input, from: { storeId: req.storeId } })),
70
+ Stream.provide(DoCtx.layer({ ...input, from: { storeId: req.storeId } })),
62
71
  Stream.mapError((cause) =>
63
72
  cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
64
73
  ? cause
65
74
  : new UnknownError({ cause }),
66
75
  ),
67
- Stream.tapErrorCause(Effect.log),
76
+ Stream.tapCause(Effect.log),
77
+ // The bridge runs this stream in a separate runtime. Install telemetry there too.
78
+ (_) => withRpcStreamSpan(_, 'RpcServer.SyncDoRpc.Pull', makeRpcSpanOptions(parent)),
79
+ observability.stream,
68
80
  ),
69
81
  'SyncDoRpc.Push': (req) =>
70
- Effect.gen(this, function* () {
71
- const { doOptions, ctx, env, storeId } = yield* DoCtx
82
+ Effect.gen({ self: this }, function* () {
83
+ const { doOptions, ctx, env, storeId } = yield* DoCtx.DoCtx
72
84
  // DO-RPC doesn't have HTTP headers context - headers are undefined
73
85
  const push = makePush({ storeId, payload: req.payload, headers: undefined, options: doOptions, ctx, env })
74
86
 
75
87
  return yield* push(req)
76
88
  }).pipe(
77
- Effect.provide(DoCtx.Default({ ...input, from: { storeId: req.storeId } })),
89
+ Effect.provide(DoCtx.layer({ ...input, from: { storeId: req.storeId } })),
78
90
  Effect.mapError((cause) =>
79
- cause._tag === 'UnknownError' || cause._tag === 'ServerAheadError' || cause._tag === 'BackendIdMismatchError'
91
+ cause._tag === 'UnknownError' ||
92
+ cause._tag === 'ServerAheadError' ||
93
+ cause._tag === 'BackendIdMismatchError'
80
94
  ? cause
81
95
  : new UnknownError({ cause }),
82
96
  ),
@@ -85,11 +99,8 @@ export const createDoRpcHandler = (
85
99
  })
86
100
 
87
101
  const handler = toDurableObjectHandler(SyncDoRpc, {
88
- layer: Layer.mergeAll(RpcLive, RpcSerialization.layerJson, HttpServer.layerContext).pipe(
89
- Layer.provide(Logger.consoleWithThread('SyncDo')),
90
- Layer.provide(Logger.minimumLogLevel(LogLevel.Debug)),
91
- ),
102
+ layer: RpcLive,
92
103
  })
93
104
 
94
- return yield* handler(payload)
105
+ return yield* handler(payload).pipe(Effect.annotateLogs({ thread: 'SyncDo' }))
95
106
  }).pipe(Effect.withSpan('createDoRpcHandler'))
@@ -1,10 +1,10 @@
1
1
  import type { CfTypes } from '@livestore/common-cf'
2
- import { Effect, HttpApp, Layer, RpcSerialization, RpcServer } from '@livestore/utils/effect'
2
+ import { Effect, HttpEffect, Layer, RpcSerialization, RpcServer } from '@livestore/utils/effect'
3
3
 
4
4
  import { SyncHttpRpc } from '../../../common/http-rpc-schema.ts'
5
5
  import * as SyncMessage from '../../../common/sync-message-types.ts'
6
6
  import { headersRecordToMap } from '../../shared.ts'
7
- import { DoCtx } from '../layer.ts'
7
+ import * as DoCtx from '../layer.ts'
8
8
  import { makeEndingPullStream } from '../pull.ts'
9
9
  import { makePush } from '../push.ts'
10
10
 
@@ -18,8 +18,8 @@ export const createHttpRpcHandler = Effect.fn('createHttpRpcHandler')(function*
18
18
  forwardedHeaders?: Record<string, string>
19
19
  }) {
20
20
  const handlerLayer = createHttpRpcLayer(forwardedHeaders)
21
- const httpApp = RpcServer.toHttpApp(SyncHttpRpc).pipe(Effect.provide(handlerLayer))
22
- const webHandler = yield* httpApp.pipe(Effect.map(HttpApp.toWebHandler))
21
+ const httpEffect = yield* RpcServer.toHttpEffect(SyncHttpRpc).pipe(Effect.provide(handlerLayer))
22
+ const webHandler = HttpEffect.toWebHandler(httpEffect)
23
23
 
24
24
  const response = yield* Effect.promise(
25
25
  () => webHandler(request as TODO as Request) as TODO as Promise<CfTypes.Response>,
@@ -43,15 +43,12 @@ const createHttpRpcLayer = (forwardedHeaders: Record<string, string> | undefined
43
43
 
44
44
  'SyncHttpRpc.Push': (req) =>
45
45
  Effect.gen(function* () {
46
- const { ctx, env, doOptions, storeId } = yield* DoCtx
47
- const push = makePush({ payload: undefined, headers, options: doOptions, storeId, ctx, env })
46
+ const { ctx, env, doOptions, storeId } = yield* DoCtx.DoCtx
47
+ const push = makePush({ payload: req.payload, headers, options: doOptions, storeId, ctx, env })
48
48
 
49
49
  return yield* push(req)
50
50
  }),
51
51
 
52
52
  'SyncHttpRpc.Ping': () => Effect.succeed(SyncMessage.Pong.make({})),
53
- }).pipe(
54
- Layer.provideMerge(RpcServer.layerProtocolHttp({ path: '/http-rpc' })),
55
- Layer.provideMerge(RpcSerialization.layerJson),
56
- )
53
+ }).pipe(Layer.provideMerge(RpcSerialization.layerJson))
57
54
  }
@@ -1,62 +1,86 @@
1
1
  import { UnknownError } from '@livestore/common'
2
2
  import { WsContext } from '@livestore/common-cf'
3
- import { Effect, identity, Layer, RpcServer, Schema, Stream } from '@livestore/utils/effect'
3
+ import { Effect, identity, Layer, Result, RpcServer, Schema, Stream } from '@livestore/utils/effect'
4
4
 
5
5
  import { SyncWsRpc } from '../../../common/ws-rpc-schema.ts'
6
6
  import { headersRecordToMap, WebSocketAttachmentSchema } from '../../shared.ts'
7
- import { DoCtx, type DoCtxInput } from '../layer.ts'
7
+ import * as DoCtx from '../layer.ts'
8
+ import { type makeObservability, rpcSpanOptions, withRpcSpan, withRpcStreamSpan } from '../observability.ts'
8
9
  import { makeEndingPullStream } from '../pull.ts'
9
10
  import { makePush } from '../push.ts'
10
11
 
11
- export const makeRpcServer = ({ doSelf, doOptions }: Omit<DoCtxInput, 'from'>) => {
12
- const handlersLayer = SyncWsRpc.toLayer({
12
+ export const makeRpcServer = ({
13
+ doSelf,
14
+ doOptions,
15
+ observability,
16
+ }: Omit<DoCtx.DoCtxInput, 'from'> & { observability: ReturnType<typeof makeObservability> }) => {
17
+ const handlersLayer = makeRpcHandlers({ doSelf, doOptions, observability })
18
+
19
+ return RpcServer.layer(SyncWsRpc).pipe(Layer.provide(handlersLayer))
20
+ }
21
+
22
+ /** Production handler layer, exported so transport tests can exercise the exact RPC composition in memory. */
23
+ export const makeRpcHandlers = ({
24
+ doSelf,
25
+ doOptions,
26
+ observability,
27
+ }: Omit<DoCtx.DoCtxInput, 'from'> & { observability: ReturnType<typeof makeObservability> }) =>
28
+ SyncWsRpc.toLayer({
13
29
  'SyncWsRpc.Pull': (req) =>
14
30
  Effect.gen(function* () {
31
+ const spanOptions = yield* rpcSpanOptions
15
32
  const headers = yield* getForwardedHeaders
16
33
  return makeEndingPullStream({ req, payload: req.payload, headers }).pipe(
17
- // Needed to keep the stream alive on the client side for phase 2 (i.e. not send the `Exit` stream RPC message)
18
- req.live === true ? Stream.concat(Stream.never) : identity,
19
- Stream.provideLayer(DoCtx.Default({ doSelf, doOptions, from: { storeId: req.storeId } })),
34
+ Stream.provide(DoCtx.layer({ doSelf, doOptions, from: { storeId: req.storeId } })),
20
35
  Stream.mapError((cause) =>
21
36
  cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
22
37
  ? cause
23
38
  : new UnknownError({ cause }),
24
39
  ),
40
+ (_) => withRpcStreamSpan(_, 'RpcServer.SyncWsRpc.Pull', spanOptions),
25
41
  )
26
- }).pipe(Stream.unwrap),
42
+ }).pipe(
43
+ Stream.unwrap,
44
+ // Drain finite history before entering the live phase, which survives DO hibernation.
45
+ observability.stream,
46
+ // Keep the client subscription open without retaining an exporter scope or timer.
47
+ req.live === true ? Stream.concat(Stream.never) : identity,
48
+ ),
27
49
  'SyncWsRpc.Push': (req) =>
28
- Effect.gen(function* () {
29
- const { doOptions, storeId, ctx, env } = yield* DoCtx
30
- const headers = yield* getForwardedHeaders
50
+ Effect.flatMap(rpcSpanOptions, (spanOptions) =>
51
+ Effect.gen(function* () {
52
+ const { doOptions, storeId, ctx, env } = yield* DoCtx.DoCtx
53
+ const headers = yield* getForwardedHeaders
31
54
 
32
- const push = makePush({ options: doOptions, storeId, payload: req.payload, headers, ctx, env })
55
+ const push = makePush({ options: doOptions, storeId, payload: req.payload, headers, ctx, env })
33
56
 
34
- return yield* push(req)
35
- }).pipe(
36
- Effect.provide(DoCtx.Default({ doSelf, doOptions, from: { storeId: req.storeId } })),
37
- Effect.mapError((cause) =>
38
- cause._tag === 'UnknownError' || cause._tag === 'ServerAheadError' || cause._tag === 'BackendIdMismatchError'
39
- ? cause
40
- : new UnknownError({ cause }),
57
+ return yield* push(req)
58
+ }).pipe(
59
+ Effect.provide(DoCtx.layer({ doSelf, doOptions, from: { storeId: req.storeId } })),
60
+ Effect.mapError((cause) =>
61
+ cause._tag === 'UnknownError' ||
62
+ cause._tag === 'ServerAheadError' ||
63
+ cause._tag === 'BackendIdMismatchError'
64
+ ? cause
65
+ : new UnknownError({ cause }),
66
+ ),
67
+ Effect.tapCauseLogPretty,
68
+ (_) => withRpcSpan(_, 'RpcServer.SyncWsRpc.Push', spanOptions),
41
69
  ),
42
- Effect.tapCauseLogPretty,
43
- ),
70
+ ).pipe(observability.effect),
44
71
  })
45
72
 
46
- return RpcServer.layer(SyncWsRpc).pipe(Layer.provide(handlersLayer))
47
- }
48
-
49
73
  /** Extracts forwarded headers from the WebSocket attachment */
50
74
  const getForwardedHeaders = Effect.gen(function* () {
51
75
  const { ws } = yield* WsContext
52
76
  const attachment = ws.deserializeAttachment()
53
- const decoded = Schema.decodeUnknownEither(WebSocketAttachmentSchema)(attachment)
54
- if (decoded._tag === 'Left') {
55
- yield* Effect.logError('Failed to decode WebSocket attachment for forwarded headers', { error: decoded.left })
77
+ const decoded = Schema.decodeUnknownResult(WebSocketAttachmentSchema)(attachment)
78
+ if (Result.isFailure(decoded) === true) {
79
+ yield* Effect.logError('Failed to decode WebSocket attachment for forwarded headers', { error: decoded.failure })
56
80
  ws.close(1011, 'invalid-attachment')
57
81
  return yield* Effect.die('Invalid WebSocket attachment (headers decode failed)')
58
82
  }
59
83
 
60
- const headers = headersRecordToMap(decoded.right.headers)
84
+ const headers = headersRecordToMap(decoded.success.headers)
61
85
  return headers
62
86
  })
@@ -1,6 +1,7 @@
1
1
  import type { UnknownError } from '@livestore/common'
2
2
  import type { CfTypes } from '@livestore/common-cf'
3
- import { Effect, Schema, UrlParams } from '@livestore/utils/effect'
3
+ import type { Effect, Layer } from '@livestore/utils/effect'
4
+ import { Result, Schema } from '@livestore/utils/effect'
4
5
 
5
6
  import type { SearchParams } from '../common/mod.ts'
6
7
  import { SearchParamsSchema, SyncMessage } from '../common/mod.ts'
@@ -20,7 +21,7 @@ export type ForwardHeadersOption = readonly string[] | ((request: CfTypes.Reques
20
21
  /** Context passed to onPush/onPull callbacks */
21
22
  export type CallbackContext = {
22
23
  storeId: StoreId
23
- payload?: Schema.JsonValue
24
+ payload?: Schema.Json
24
25
  /** Headers forwarded from the request (only present if `forwardHeaders` is configured) */
25
26
  headers?: ForwardedHeaders
26
27
  }
@@ -106,12 +107,19 @@ export type MakeDurableObjectClassOptions = {
106
107
  responseHeaders?: Record<string, string>
107
108
  }
108
109
 
109
- otel?: {
110
- baseUrl?: string
111
- serviceName?: string
112
- }
110
+ /** Optional telemetry. Omit to avoid creating an exporter or sending telemetry. */
111
+ otel?: SyncBackendOtelOptions
113
112
  }
114
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
+
115
123
  export type StoreId = string
116
124
  export type DurableObjectId = string
117
125
 
@@ -133,8 +141,10 @@ export type DurableObjectId = string
133
141
  */
134
142
  export const PERSISTENCE_FORMAT_VERSION = 7
135
143
 
136
- export const encodeOutgoingMessage = Schema.encodeSync(Schema.parseJson(SyncMessage.BackendToClientMessage))
137
- export const encodeIncomingMessage = Schema.encodeSync(Schema.parseJson(SyncMessage.ClientToBackendMessage))
144
+ export const encodeOutgoingMessage = Schema.encodeSync(Schema.fromJsonString(SyncMessage.BackendToClientMessage))
145
+ export const encodeIncomingMessage = Schema.encodeSync(Schema.fromJsonString(SyncMessage.ClientToBackendMessage))
146
+
147
+ const SearchParamsFromUrlSearchParams = Schema.fromURLSearchParams(SearchParamsSchema)
138
148
 
139
149
  /**
140
150
  * Extracts the LiveStore sync search parameters from a request. Returns
@@ -143,20 +153,19 @@ export const encodeIncomingMessage = Schema.encodeSync(Schema.parseJson(SyncMess
143
153
  */
144
154
  export const matchSyncRequest = (request: CfTypes.Request): SearchParams | undefined => {
145
155
  const url = new URL(request.url)
146
- const urlParams = UrlParams.fromInput(url.searchParams)
147
- const paramsResult = UrlParams.schemaStruct(SearchParamsSchema)(urlParams).pipe(Effect.option, Effect.runSync)
156
+ const paramsResult = Schema.decodeResult(SearchParamsFromUrlSearchParams)(url.searchParams)
148
157
 
149
- if (paramsResult._tag === 'None') {
158
+ if (Result.isFailure(paramsResult) === true) {
150
159
  return undefined
151
160
  }
152
161
 
153
- return paramsResult.value
162
+ return paramsResult.success
154
163
  }
155
164
 
156
165
  // RPC subscription storage (TODO refactor)
157
166
  export type RpcSubscription = {
158
167
  storeId: StoreId
159
- payload?: Schema.JsonValue
168
+ payload?: Schema.Json
160
169
  subscribedAt: number
161
170
  /** Effect RPC request ID */
162
171
  requestId: string
@@ -166,6 +175,9 @@ export type RpcSubscription = {
166
175
  }
167
176
  }
168
177
 
178
+ /** Key prefix for DO-RPC subscriptions persisted in the DO's synchronous KV storage. */
179
+ export const rpcSubscriptionKeyPrefix = 'rpc-sub:'
180
+
169
181
  /**
170
182
  * Durable Object interface supporting the DO RPC protocol for DO <> DO syncing.
171
183
  */
@@ -174,15 +186,15 @@ export interface SyncBackendRpcInterface {
174
186
  rpc(payload: Uint8Array): Promise<Uint8Array | CfTypes.ReadableStream>
175
187
  }
176
188
 
177
- export const WebSocketAttachmentSchema = Schema.parseJson(
189
+ export const WebSocketAttachmentSchema = Schema.fromJsonString(
178
190
  Schema.Struct({
179
191
  // Same across all websocket connections
180
192
  storeId: Schema.String,
181
193
  // Different for each websocket connection
182
- payload: Schema.optional(Schema.JsonValue),
183
- pullRequestIds: Schema.Array(Schema.String),
194
+ payload: Schema.optional(Schema.Json),
195
+ pullRequestIds: Schema.Array(Schema.Union([Schema.String, Schema.Finite])),
184
196
  // Headers forwarded from the initial request (via forwardHeaders option)
185
- headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.String })),
197
+ headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
186
198
  }),
187
199
  )
188
200
 
@@ -2,14 +2,13 @@ import { env as importedEnv } from 'cloudflare:workers'
2
2
 
3
3
  import { UnknownError } from '@livestore/common'
4
4
  import type { HelperTypes } from '@livestore/common-cf'
5
- import { Effect, Schema } from '@livestore/utils/effect'
5
+ import { CfDeclare } from '@livestore/common-cf/declare'
6
+ import { Effect, Result, Schema } from '@livestore/utils/effect'
6
7
 
7
8
  import type { CfTypes, SearchParams } from '../common/mod.ts'
8
- import type { CfDeclare } from './mod.ts'
9
9
  import { type Env, type ForwardedHeaders, matchSyncRequest } from './shared.ts'
10
10
 
11
- // NOTE We need to redeclare runtime types here to avoid type conflicts with the lib.dom Response type.
12
- declare class Response extends CfDeclare.Response {}
11
+ const Response = CfDeclare.Response
13
12
 
14
13
  // HINT: If we ever extend user's custom worker RPC, type T can help here with expected return type safety. Currently unused.
15
14
  export type CFWorker<TEnv extends Env = Env, _T extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined> = {
@@ -31,7 +30,7 @@ export type ValidatePayloadContext = {
31
30
  * Options accepted by {@link makeWorker}. The Durable Object binding has to be
32
31
  * supplied explicitly so we never fall back to deprecated defaults when Cloudflare config changes.
33
32
  */
34
- export type MakeWorkerOptions<TEnv extends Env = Env, TSyncPayload = Schema.JsonValue> = {
33
+ export type MakeWorkerOptions<TEnv extends Env = Env, TSyncPayload = Schema.Json> = {
35
34
  /**
36
35
  * Binding name of the sync Durable Object declared in wrangler config.
37
36
  */
@@ -40,7 +39,7 @@ export type MakeWorkerOptions<TEnv extends Env = Env, TSyncPayload = Schema.Json
40
39
  * Optionally pass a schema to decode the client-provided payload into a typed object
41
40
  * before calling {@link validatePayload}. If omitted, the raw JSON value is forwarded.
42
41
  */
43
- syncPayloadSchema?: Schema.Schema<TSyncPayload>
42
+ syncPayloadSchema?: Schema.Decoder<TSyncPayload>
44
43
  /**
45
44
  * Validates the (optionally decoded) payload during WebSocket connection establishment.
46
45
  * If {@link syncPayloadSchema} is provided, `payload` will be of the schema's inferred type.
@@ -74,7 +73,7 @@ export type MakeWorkerOptions<TEnv extends Env = Env, TSyncPayload = Schema.Json
74
73
  export const makeWorker = <
75
74
  TEnv extends Env = Env,
76
75
  TDurableObjectRpc extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined,
77
- TSyncPayload = Schema.JsonValue,
76
+ TSyncPayload = Schema.Json,
78
77
  >(
79
78
  options: MakeWorkerOptions<TEnv, TSyncPayload>,
80
79
  ): CFWorker<TEnv, TDurableObjectRpc> => {
@@ -150,7 +149,7 @@ const requestHeadersToMap = (request: CfTypes.Request): ForwardedHeaders => {
150
149
  *
151
150
  * @example Token-based authentication
152
151
  * ```ts
153
- * const validatePayload = (payload: Schema.JsonValue | undefined, context: { storeId: string }) => {
152
+ * const validatePayload = (payload: Schema.Json | undefined, context: { storeId: string }) => {
154
153
  * if (payload?.authToken !== 'insecure-token-change-me') {
155
154
  * throw new Error('Invalid auth token')
156
155
  * }
@@ -159,7 +158,7 @@ const requestHeadersToMap = (request: CfTypes.Request): ForwardedHeaders => {
159
158
  *
160
159
  * @example Cookie-based authentication
161
160
  * ```ts
162
- * const validatePayload = async (payload: Schema.JsonValue | undefined, { storeId, headers }) => {
161
+ * const validatePayload = async (payload: Schema.Json | undefined, { storeId, headers }) => {
163
162
  * const cookie = headers.get('cookie')
164
163
  * const session = await validateSessionFromCookie(cookie)
165
164
  * if (!session) throw new Error('Unauthorized')
@@ -172,7 +171,7 @@ export const handleSyncRequest = <
172
171
  TEnv extends Env = Env,
173
172
  TDurableObjectRpc extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined,
174
173
  CFHostMetada = unknown,
175
- TSyncPayload = Schema.JsonValue,
174
+ TSyncPayload = Schema.Json,
176
175
  >({
177
176
  request,
178
177
  searchParams: { storeId, payload, transport },
@@ -201,29 +200,29 @@ export const handleSyncRequest = <
201
200
  // Always decode with the supplied schema when present, even if payload is undefined.
202
201
  // This ensures required payloads are enforced by the schema.
203
202
  if (syncPayloadSchema !== undefined) {
204
- const decodedEither = Schema.decodeUnknownEither(syncPayloadSchema)(payload)
205
- if (decodedEither._tag === 'Left') {
206
- const message = decodedEither.left.toString()
203
+ const decodedResult = Schema.decodeResult(syncPayloadSchema)(payload)
204
+ if (Result.isFailure(decodedResult) === true) {
205
+ const message = decodedResult.failure.toString()
207
206
  console.error('Invalid payload (decode failed)', message)
208
207
  return new Response(message, { status: 400, ...(headers !== undefined ? { headers } : {}) })
209
208
  }
210
209
 
211
210
  const result = yield* Effect.promise(async () =>
212
- validatePayload(decodedEither.right, { storeId, headers: requestHeaders }),
213
- ).pipe(UnknownError.mapToUnknownError, Effect.either)
211
+ validatePayload(decodedResult.success, { storeId, headers: requestHeaders }),
212
+ ).pipe(UnknownError.mapToUnknownError, Effect.result)
214
213
 
215
- if (result._tag === 'Left') {
216
- console.error('Invalid payload (validation failed)', result.left)
217
- return new Response(result.left.toString(), { status: 400, ...(headers !== undefined ? { headers } : {}) })
214
+ if (Result.isFailure(result) === true) {
215
+ console.error('Invalid payload (validation failed)', result.failure)
216
+ return new Response(result.failure.toString(), { status: 400, ...(headers !== undefined ? { headers } : {}) })
218
217
  }
219
218
  } else {
220
219
  const result = yield* Effect.promise(async () =>
221
220
  validatePayload(payload as TSyncPayload, { storeId, headers: requestHeaders }),
222
- ).pipe(UnknownError.mapToUnknownError, Effect.either)
221
+ ).pipe(UnknownError.mapToUnknownError, Effect.result)
223
222
 
224
- if (result._tag === 'Left') {
225
- console.error('Invalid payload (validation failed)', result.left)
226
- return new Response(result.left.toString(), { status: 400, ...(headers !== undefined ? { headers } : {}) })
223
+ if (Result.isFailure(result) === true) {
224
+ console.error('Invalid payload (validation failed)', result.failure)
225
+ return new Response(result.failure.toString(), { status: 400, ...(headers !== undefined ? { headers } : {}) })
227
226
  }
228
227
  }
229
228
  }