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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
  3. package/dist/cf-worker/do/durable-object.js +24 -11
  4. package/dist/cf-worker/do/durable-object.js.map +1 -1
  5. package/dist/cf-worker/do/layer.d.ts +22 -18
  6. package/dist/cf-worker/do/layer.d.ts.map +1 -1
  7. package/dist/cf-worker/do/layer.js +43 -33
  8. package/dist/cf-worker/do/layer.js.map +1 -1
  9. package/dist/cf-worker/do/pull.d.ts +3 -3
  10. package/dist/cf-worker/do/pull.d.ts.map +1 -1
  11. package/dist/cf-worker/do/pull.js +17 -18
  12. package/dist/cf-worker/do/pull.js.map +1 -1
  13. package/dist/cf-worker/do/push.d.ts +16 -3
  14. package/dist/cf-worker/do/push.d.ts.map +1 -1
  15. package/dist/cf-worker/do/push.js +69 -49
  16. package/dist/cf-worker/do/push.js.map +1 -1
  17. package/dist/cf-worker/do/push.test.d.ts +2 -0
  18. package/dist/cf-worker/do/push.test.d.ts.map +1 -0
  19. package/dist/cf-worker/do/push.test.js +30 -0
  20. package/dist/cf-worker/do/push.test.js.map +1 -0
  21. package/dist/cf-worker/do/sqlite.d.ts +34 -46
  22. package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
  23. package/dist/cf-worker/do/sqlite.js +1 -1
  24. package/dist/cf-worker/do/sqlite.js.map +1 -1
  25. package/dist/cf-worker/do/sync-storage.d.ts +7 -6
  26. package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
  27. package/dist/cf-worker/do/sync-storage.js +26 -14
  28. package/dist/cf-worker/do/sync-storage.js.map +1 -1
  29. package/dist/cf-worker/do/transport/do-rpc-server.d.ts +2 -2
  30. package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
  31. package/dist/cf-worker/do/transport/do-rpc-server.js +21 -19
  32. package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
  33. package/dist/cf-worker/do/transport/http-rpc-server.d.ts +2 -2
  34. package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
  35. package/dist/cf-worker/do/transport/http-rpc-server.js +7 -7
  36. package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
  37. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +2 -2
  38. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
  39. package/dist/cf-worker/do/transport/ws-rpc-server.js +9 -9
  40. package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
  41. package/dist/cf-worker/shared.d.ts +22 -19
  42. package/dist/cf-worker/shared.d.ts.map +1 -1
  43. package/dist/cf-worker/shared.js +13 -11
  44. package/dist/cf-worker/shared.js.map +1 -1
  45. package/dist/cf-worker/worker.d.ts +6 -6
  46. package/dist/cf-worker/worker.d.ts.map +1 -1
  47. package/dist/cf-worker/worker.js +16 -14
  48. package/dist/cf-worker/worker.js.map +1 -1
  49. package/dist/client/transport/do-rpc-client.d.ts +13 -4
  50. package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
  51. package/dist/client/transport/do-rpc-client.js +55 -26
  52. package/dist/client/transport/do-rpc-client.js.map +1 -1
  53. package/dist/client/transport/http-rpc-client.d.ts +3 -3
  54. package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
  55. package/dist/client/transport/http-rpc-client.js +30 -21
  56. package/dist/client/transport/http-rpc-client.js.map +1 -1
  57. package/dist/client/transport/ws-rpc-client.d.ts +3 -3
  58. package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
  59. package/dist/client/transport/ws-rpc-client.js +20 -16
  60. package/dist/client/transport/ws-rpc-client.js.map +1 -1
  61. package/dist/common/do-rpc-schema.d.ts +38 -39
  62. package/dist/common/do-rpc-schema.d.ts.map +1 -1
  63. package/dist/common/do-rpc-schema.js +3 -3
  64. package/dist/common/do-rpc-schema.js.map +1 -1
  65. package/dist/common/http-rpc-schema.d.ts +36 -37
  66. package/dist/common/http-rpc-schema.d.ts.map +1 -1
  67. package/dist/common/http-rpc-schema.js +5 -5
  68. package/dist/common/http-rpc-schema.js.map +1 -1
  69. package/dist/common/mod.d.ts +4 -5
  70. package/dist/common/mod.d.ts.map +1 -1
  71. package/dist/common/mod.js +3 -4
  72. package/dist/common/mod.js.map +1 -1
  73. package/dist/common/sync-message-types.d.ts +116 -133
  74. package/dist/common/sync-message-types.d.ts.map +1 -1
  75. package/dist/common/sync-message-types.js +26 -14
  76. package/dist/common/sync-message-types.js.map +1 -1
  77. package/dist/common/ws-rpc-schema.d.ts +33 -34
  78. package/dist/common/ws-rpc-schema.d.ts.map +1 -1
  79. package/dist/common/ws-rpc-schema.js +4 -4
  80. package/dist/common/ws-rpc-schema.js.map +1 -1
  81. package/package.json +21 -37
  82. package/src/cf-worker/do/durable-object.ts +45 -36
  83. package/src/cf-worker/do/layer.ts +142 -99
  84. package/src/cf-worker/do/pull.ts +28 -28
  85. package/src/cf-worker/do/push.test.ts +40 -0
  86. package/src/cf-worker/do/push.ts +138 -115
  87. package/src/cf-worker/do/sqlite.ts +1 -1
  88. package/src/cf-worker/do/sync-storage.ts +34 -21
  89. package/src/cf-worker/do/transport/do-rpc-server.ts +23 -34
  90. package/src/cf-worker/do/transport/http-rpc-server.ts +7 -10
  91. package/src/cf-worker/do/transport/ws-rpc-server.ts +10 -10
  92. package/src/cf-worker/shared.ts +18 -13
  93. package/src/cf-worker/worker.ts +21 -22
  94. package/src/client/transport/do-rpc-client.ts +82 -40
  95. package/src/client/transport/http-rpc-client.ts +47 -37
  96. package/src/client/transport/ws-rpc-client.ts +29 -26
  97. package/src/common/do-rpc-schema.ts +3 -3
  98. package/src/common/http-rpc-schema.ts +5 -5
  99. package/src/common/mod.ts +3 -4
  100. package/src/common/sync-message-types.ts +16 -16
  101. package/src/common/ws-rpc-schema.ts +4 -4
@@ -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,14 +1,14 @@
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
8
  import { makeEndingPullStream } from '../pull.ts'
9
9
  import { makePush } from '../push.ts'
10
10
 
11
- export const makeRpcServer = ({ doSelf, doOptions }: Omit<DoCtxInput, 'from'>) => {
11
+ export const makeRpcServer = ({ doSelf, doOptions }: Omit<DoCtx.DoCtxInput, 'from'>) => {
12
12
  const handlersLayer = SyncWsRpc.toLayer({
13
13
  'SyncWsRpc.Pull': (req) =>
14
14
  Effect.gen(function* () {
@@ -16,7 +16,7 @@ export const makeRpcServer = ({ doSelf, doOptions }: Omit<DoCtxInput, 'from'>) =
16
16
  return makeEndingPullStream({ req, payload: req.payload, headers }).pipe(
17
17
  // Needed to keep the stream alive on the client side for phase 2 (i.e. not send the `Exit` stream RPC message)
18
18
  req.live === true ? Stream.concat(Stream.never) : identity,
19
- Stream.provideLayer(DoCtx.Default({ doSelf, doOptions, from: { storeId: req.storeId } })),
19
+ Stream.provide(DoCtx.layer({ doSelf, doOptions, from: { storeId: req.storeId } })),
20
20
  Stream.mapError((cause) =>
21
21
  cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
22
22
  ? cause
@@ -26,14 +26,14 @@ export const makeRpcServer = ({ doSelf, doOptions }: Omit<DoCtxInput, 'from'>) =
26
26
  }).pipe(Stream.unwrap),
27
27
  'SyncWsRpc.Push': (req) =>
28
28
  Effect.gen(function* () {
29
- const { doOptions, storeId, ctx, env } = yield* DoCtx
29
+ const { doOptions, storeId, ctx, env } = yield* DoCtx.DoCtx
30
30
  const headers = yield* getForwardedHeaders
31
31
 
32
32
  const push = makePush({ options: doOptions, storeId, payload: req.payload, headers, ctx, env })
33
33
 
34
34
  return yield* push(req)
35
35
  }).pipe(
36
- Effect.provide(DoCtx.Default({ doSelf, doOptions, from: { storeId: req.storeId } })),
36
+ Effect.provide(DoCtx.layer({ doSelf, doOptions, from: { storeId: req.storeId } })),
37
37
  Effect.mapError((cause) =>
38
38
  cause._tag === 'UnknownError' || cause._tag === 'ServerAheadError' || cause._tag === 'BackendIdMismatchError'
39
39
  ? cause
@@ -50,13 +50,13 @@ export const makeRpcServer = ({ doSelf, doOptions }: Omit<DoCtxInput, 'from'>) =
50
50
  const getForwardedHeaders = Effect.gen(function* () {
51
51
  const { ws } = yield* WsContext
52
52
  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 })
53
+ const decoded = Schema.decodeUnknownResult(WebSocketAttachmentSchema)(attachment)
54
+ if (Result.isFailure(decoded) === true) {
55
+ yield* Effect.logError('Failed to decode WebSocket attachment for forwarded headers', { error: decoded.failure })
56
56
  ws.close(1011, 'invalid-attachment')
57
57
  return yield* Effect.die('Invalid WebSocket attachment (headers decode failed)')
58
58
  }
59
59
 
60
- const headers = headersRecordToMap(decoded.right.headers)
60
+ const headers = headersRecordToMap(decoded.success.headers)
61
61
  return headers
62
62
  })
@@ -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 } 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
  }
@@ -133,8 +134,10 @@ export type DurableObjectId = string
133
134
  */
134
135
  export const PERSISTENCE_FORMAT_VERSION = 7
135
136
 
136
- export const encodeOutgoingMessage = Schema.encodeSync(Schema.parseJson(SyncMessage.BackendToClientMessage))
137
- export const encodeIncomingMessage = Schema.encodeSync(Schema.parseJson(SyncMessage.ClientToBackendMessage))
137
+ export const encodeOutgoingMessage = Schema.encodeSync(Schema.fromJsonString(SyncMessage.BackendToClientMessage))
138
+ export const encodeIncomingMessage = Schema.encodeSync(Schema.fromJsonString(SyncMessage.ClientToBackendMessage))
139
+
140
+ const SearchParamsFromUrlSearchParams = Schema.fromURLSearchParams(SearchParamsSchema)
138
141
 
139
142
  /**
140
143
  * Extracts the LiveStore sync search parameters from a request. Returns
@@ -143,20 +146,19 @@ export const encodeIncomingMessage = Schema.encodeSync(Schema.parseJson(SyncMess
143
146
  */
144
147
  export const matchSyncRequest = (request: CfTypes.Request): SearchParams | undefined => {
145
148
  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)
149
+ const paramsResult = Schema.decodeUnknownResult(SearchParamsFromUrlSearchParams)(url.searchParams)
148
150
 
149
- if (paramsResult._tag === 'None') {
151
+ if (Result.isFailure(paramsResult) === true) {
150
152
  return undefined
151
153
  }
152
154
 
153
- return paramsResult.value
155
+ return paramsResult.success
154
156
  }
155
157
 
156
158
  // RPC subscription storage (TODO refactor)
157
159
  export type RpcSubscription = {
158
160
  storeId: StoreId
159
- payload?: Schema.JsonValue
161
+ payload?: Schema.Json
160
162
  subscribedAt: number
161
163
  /** Effect RPC request ID */
162
164
  requestId: string
@@ -166,6 +168,9 @@ export type RpcSubscription = {
166
168
  }
167
169
  }
168
170
 
171
+ /** Key prefix for DO-RPC subscriptions persisted in the DO's synchronous KV storage. */
172
+ export const rpcSubscriptionKeyPrefix = 'rpc-sub:'
173
+
169
174
  /**
170
175
  * Durable Object interface supporting the DO RPC protocol for DO <> DO syncing.
171
176
  */
@@ -174,15 +179,15 @@ export interface SyncBackendRpcInterface {
174
179
  rpc(payload: Uint8Array): Promise<Uint8Array | CfTypes.ReadableStream>
175
180
  }
176
181
 
177
- export const WebSocketAttachmentSchema = Schema.parseJson(
182
+ export const WebSocketAttachmentSchema = Schema.fromJsonString(
178
183
  Schema.Struct({
179
184
  // Same across all websocket connections
180
185
  storeId: Schema.String,
181
186
  // Different for each websocket connection
182
- payload: Schema.optional(Schema.JsonValue),
183
- pullRequestIds: Schema.Array(Schema.String),
187
+ payload: Schema.optional(Schema.Json),
188
+ pullRequestIds: Schema.Array(Schema.Union([Schema.String, Schema.Finite])),
184
189
  // Headers forwarded from the initial request (via forwardHeaders option)
185
- headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.String })),
190
+ headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
186
191
  }),
187
192
  )
188
193
 
@@ -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.decodeUnknownResult(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
  }
@@ -1,18 +1,19 @@
1
1
  import { SyncBackend, UnknownError } from '@livestore/common'
2
- import { splitChunkBySize } from '@livestore/common/sync'
3
2
  import { type CfTypes, layerProtocolDurableObject } from '@livestore/common-cf'
4
- import { omit, shouldNeverHappen } from '@livestore/utils'
3
+ import { splitArrayBySize } from '@livestore/common/sync'
4
+ import { shouldNeverHappen } from '@livestore/utils'
5
5
  import {
6
- Chunk,
7
6
  Effect,
8
7
  identity,
9
8
  Layer,
10
- Mailbox,
11
9
  Option,
10
+ Queue,
11
+ ReadonlyArray as EffectArray,
12
12
  RpcClient,
13
13
  RpcSerialization,
14
14
  Schema,
15
15
  Stream,
16
+ Struct,
16
17
  SubscriptionRef,
17
18
  } from '@livestore/utils/effect'
18
19
 
@@ -22,15 +23,18 @@ import { SyncDoRpc } from '../../common/do-rpc-schema.ts'
22
23
  import { SyncMessage } from '../../common/mod.ts'
23
24
  import type { SyncMetadata } from '../../common/sync-message-types.ts'
24
25
 
25
- export interface SyncBackendRpcStub extends CfTypes.DurableObjectStub, SyncBackendRpcInterface {}
26
+ type PushBatchItem = SyncMessage.PushRequest['batch'][number]
26
27
 
27
- // TODO we probably need better scoping for the requestIdMailboxMap (i.e. support multiple stores, ...)
28
- type EffectRpcRequestId = string // 0, 1, 2, ...
29
- const requestIdMailboxMap = new Map<EffectRpcRequestId, Mailbox.Mailbox<SyncMessage.PullResponse>>()
28
+ export interface SyncBackendRpcStub extends CfTypes.DurableObjectStub, SyncBackendRpcInterface {}
30
29
 
31
30
  export interface DoRpcSyncOptions {
32
31
  /** Durable Object stub that implements the SyncDoRpc interface */
33
32
  syncBackendStub: SyncBackendRpcStub
33
+ /**
34
+ * State handle of the client DurableObject running this sync backend. Scopes live-pull routing to
35
+ * this instance so it resets when the DO is reconstructed (see {@link handleSyncUpdateRpc}).
36
+ */
37
+ durableObjectState: CfTypes.DurableObjectState
34
38
  /** Information about this DurableObject instance so the Sync DO instance can call back to this instance */
35
39
  durableObjectContext: {
36
40
  /** See `wrangler.toml` for the binding name */
@@ -46,7 +50,11 @@ export interface DoRpcSyncOptions {
46
50
  * Used internally by `@livestore/adapter-cf` to connect to the sync backend.
47
51
  */
48
52
  export const makeDoRpcSync =
49
- ({ syncBackendStub, durableObjectContext }: DoRpcSyncOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
53
+ ({
54
+ syncBackendStub,
55
+ durableObjectState,
56
+ durableObjectContext,
57
+ }: DoRpcSyncOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
50
58
  ({ storeId, payload }) =>
51
59
  Effect.gen(function* () {
52
60
  const isConnected = yield* SubscriptionRef.make(true)
@@ -66,7 +74,7 @@ export const makeDoRpcSync =
66
74
  const backendIdHelper = yield* SyncBackend.makeBackendIdHelper
67
75
 
68
76
  const pull: SyncBackend.SyncBackend<SyncMetadata>['pull'] = (cursor, options) =>
69
- rpcClient.SyncDoRpc.Pull({
77
+ rpcClient['SyncDoRpc.Pull']({
70
78
  cursor: cursor.pipe(
71
79
  Option.map((a) => ({
72
80
  eventSequenceNumber: a.eventSequenceNumber,
@@ -82,18 +90,22 @@ export const makeDoRpcSync =
82
90
  if (res._tag === 'None')
83
91
  return shouldNeverHappen('There should at least be a no-more page info response')
84
92
 
85
- const mailbox = yield* Mailbox.make<SyncMessage.PullResponse>().pipe(
86
- Effect.acquireRelease((mailbox) => mailbox.shutdown),
93
+ const requestId = res.value.rpcRequestId
94
+ const routing = pullRoutingFor(durableObjectState)
95
+
96
+ const queue = yield* Effect.acquireRelease(Queue.unbounded<SyncMessage.PullResponse>(), (queue) =>
97
+ // Drop the routing entry on release so it can't outlive its (now shut-down) queue
98
+ Effect.sync(() => routing.delete(requestId)).pipe(Effect.andThen(Queue.shutdown(queue))),
87
99
  )
88
100
 
89
- requestIdMailboxMap.set(res.value.rpcRequestId, mailbox)
101
+ routing.set(requestId, queue)
90
102
 
91
- return Mailbox.toStream(mailbox)
92
- }).pipe(Stream.unwrapScoped),
103
+ return Stream.fromQueue(queue)
104
+ }).pipe(Stream.unwrap),
93
105
  )
94
106
  : identity,
95
107
  Stream.tap((res) => backendIdHelper.lazySet(res.backendId)),
96
- Stream.map((res) => omit(res, ['backendId'])),
108
+ Stream.map((res) => Struct.omit(res, ['backendId'])),
97
109
  Stream.mapError((cause) =>
98
110
  cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
99
111
  ? cause
@@ -109,22 +121,22 @@ export const makeDoRpcSync =
109
121
  }
110
122
 
111
123
  const backendId = backendIdHelper.get()
112
- const batchChunks = yield* Chunk.fromIterable(batch).pipe(
113
- splitChunkBySize({
114
- maxItems: MAX_PUSH_EVENTS_PER_REQUEST,
115
- maxBytes: MAX_DO_RPC_REQUEST_BYTES,
116
- encode: (items) => ({
117
- batch: items,
118
- storeId,
119
- backendId,
120
- }),
124
+ if (EffectArray.isReadonlyArrayNonEmpty(batch) === false) {
125
+ return
126
+ }
127
+
128
+ const batchChunks = yield* splitArrayBySize({
129
+ maxItems: MAX_PUSH_EVENTS_PER_REQUEST,
130
+ maxBytes: MAX_DO_RPC_REQUEST_BYTES,
131
+ encode: (items: ReadonlyArray<PushBatchItem>) => ({
132
+ batch: items,
133
+ storeId,
134
+ backendId,
121
135
  }),
122
- Effect.mapError((cause) => new UnknownError({ cause })),
123
- )
136
+ })(batch).pipe(Effect.mapError((cause) => new UnknownError({ cause })))
124
137
 
125
- for (const chunk of Chunk.toReadonlyArray(batchChunks)) {
126
- const chunkArray = Chunk.toReadonlyArray(chunk)
127
- yield* rpcClient.SyncDoRpc.Push({ batch: chunkArray, storeId, backendId })
138
+ for (const batchChunk of batchChunks) {
139
+ yield* rpcClient['SyncDoRpc.Push']({ batch: batchChunk, storeId, backendId })
128
140
  }
129
141
  },
130
142
  Effect.mapError((cause) =>
@@ -134,7 +146,7 @@ export const makeDoRpcSync =
134
146
  ),
135
147
  )
136
148
 
137
- const ping: SyncBackend.SyncBackend<{ createdAt: string }>['ping'] = rpcClient.SyncDoRpc.Ping({
149
+ const ping: SyncBackend.SyncBackend<{ createdAt: string }>['ping'] = rpcClient['SyncDoRpc.Ping']({
138
150
  storeId,
139
151
  payload,
140
152
  }).pipe(UnknownError.mapToUnknownError, Effect.withSpan('rpc-sync-client:ping'))
@@ -159,6 +171,9 @@ export const makeDoRpcSync =
159
171
  }).pipe(Effect.withSpan('rpc-sync-client:makeDoRpcSync'))
160
172
 
161
173
  /**
174
+ * Routes an update from the sync backend into this client's live pull.
175
+ *
176
+ * Only `ctx` and `payload` go here; `storeId` is for reloading your store on a rebuilt DO (see example).
162
177
  *
163
178
  * ```ts
164
179
  * import { DurableObject } from 'cloudflare:workers'
@@ -167,25 +182,31 @@ export const makeDoRpcSync =
167
182
  * export class MyDurableObject extends DurableObject implements ClientDoWithRpcCallback {
168
183
  * // ...
169
184
  *
170
- * async syncUpdateRpc(payload: RpcMessage.ResponseChunkEncoded) {
171
- * return handleSyncUpdateRpc(payload)
185
+ * async syncUpdateRpc(payload: Uint8Array<ArrayBuffer>, storeId: string) {
186
+ * await this.getStore(storeId)
187
+ * return handleSyncUpdateRpc(this.ctx, payload)
172
188
  * }
173
189
  * }
174
190
  * ```
175
191
  */
176
- export const handleSyncUpdateRpc = (payload: unknown) =>
192
+ export const handleSyncUpdateRpc = (ctx: CfTypes.DurableObjectState, payload: Uint8Array<ArrayBuffer>) =>
177
193
  Effect.gen(function* () {
178
- const decodedPayload = yield* Schema.decodeUnknown(ResponseChunkEncoded)(payload)
179
- const decoded = yield* Schema.decodeUnknown(SyncMessage.PullResponse)(decodedPayload.values[0])
194
+ const parser = RpcSerialization.msgPack.makeUnsafe()
195
+ const decodedMessage = parser.decode(payload)
196
+ const [response] = Array.isArray(decodedMessage) === true ? decodedMessage.flat(1) : [decodedMessage]
197
+ const decodedPayload = yield* Schema.decodeUnknownEffect(ResponseChunkEncoded)(response)
198
+ const decoded = yield* Schema.decodeUnknownEffect(Schema.toCodecJson(SyncMessage.PullResponse))(
199
+ decodedPayload.values[0],
200
+ )
180
201
 
181
- const pullStreamMailbox = requestIdMailboxMap.get(decodedPayload.requestId)
202
+ const pullStreamQueue = pullRoutingFor(ctx).get(decodedPayload.requestId)
182
203
 
183
- if (pullStreamMailbox === undefined) {
204
+ if (pullStreamQueue === undefined) {
184
205
  // Case: DO was hibernated, so we need to manually update the store
185
- yield* Effect.log(`No mailbox found for ${decodedPayload.requestId}`)
206
+ yield* Effect.log(`No pull stream queue found for ${decodedPayload.requestId}`)
186
207
  } else {
187
208
  // Case: DO was still alive, so the existing `pull` will pick up the new events
188
- yield* pullStreamMailbox.offer(decoded)
209
+ yield* Queue.offer(pullStreamQueue, decoded)
189
210
  }
190
211
  }).pipe(Effect.withSpan('rpc-sync-client:rpcCallback'), Effect.tapCauseLogPretty, Effect.runPromise)
191
212
 
@@ -193,3 +214,24 @@ const ResponseChunkEncoded = Schema.Struct({
193
214
  requestId: Schema.String,
194
215
  values: Schema.Array(Schema.Any),
195
216
  })
217
+
218
+ type EffectRpcRequestId = string // 0, 1, 2, ...
219
+ type PullRouting = Map<EffectRpcRequestId, Queue.Queue<SyncMessage.PullResponse>>
220
+
221
+ /**
222
+ * Per-instance map from a live pull's request id to its response queue, keyed off the client DO's
223
+ * `DurableObjectState` (not a module global) for two independent reasons:
224
+ * - Per-instance: a reconstructed DO starts empty, so a reverse-RPC whose store is gone hits the
225
+ * `handleSyncUpdateRpc` drop branch (the recovery hook) instead of a stale queue that outlived it.
226
+ * - Keyed by request id: a late chunk from a superseded pull generation finds no entry and is
227
+ * dropped here — never reaching `SyncState.merge`, which dies on an event at/below the upstream head.
228
+ */
229
+ const pullRoutingByInstance = new WeakMap<CfTypes.DurableObjectState, PullRouting>()
230
+
231
+ const pullRoutingFor = (ctx: CfTypes.DurableObjectState): PullRouting => {
232
+ const existing = pullRoutingByInstance.get(ctx)
233
+ if (existing !== undefined) return existing
234
+ const routing: PullRouting = new Map()
235
+ pullRoutingByInstance.set(ctx, routing)
236
+ return routing
237
+ }