@livestore/sync-cf 0.4.0-dev.21 → 0.4.0-dev.23

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 (82) hide show
  1. package/README.md +6 -7
  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 +12 -8
  5. package/dist/cf-worker/do/durable-object.js.map +1 -1
  6. package/dist/cf-worker/do/layer.d.ts +1 -1
  7. package/dist/cf-worker/do/layer.d.ts.map +1 -1
  8. package/dist/cf-worker/do/layer.js +2 -2
  9. package/dist/cf-worker/do/layer.js.map +1 -1
  10. package/dist/cf-worker/do/pull.d.ts +7 -2
  11. package/dist/cf-worker/do/pull.d.ts.map +1 -1
  12. package/dist/cf-worker/do/pull.js +12 -6
  13. package/dist/cf-worker/do/pull.js.map +1 -1
  14. package/dist/cf-worker/do/push.d.ts +5 -4
  15. package/dist/cf-worker/do/push.d.ts.map +1 -1
  16. package/dist/cf-worker/do/push.js +18 -11
  17. package/dist/cf-worker/do/push.js.map +1 -1
  18. package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
  19. package/dist/cf-worker/do/sqlite.js.map +1 -1
  20. package/dist/cf-worker/do/sync-storage.d.ts +1 -1
  21. package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
  22. package/dist/cf-worker/do/sync-storage.js +2 -1
  23. package/dist/cf-worker/do/sync-storage.js.map +1 -1
  24. package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
  25. package/dist/cf-worker/do/transport/do-rpc-server.js +13 -7
  26. package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
  27. package/dist/cf-worker/do/transport/http-rpc-server.d.ts +2 -1
  28. package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
  29. package/dist/cf-worker/do/transport/http-rpc-server.js +17 -14
  30. package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
  31. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +2 -1
  32. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
  33. package/dist/cf-worker/do/transport/ws-rpc-server.js +30 -8
  34. package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
  35. package/dist/cf-worker/shared.d.ts +60 -15
  36. package/dist/cf-worker/shared.d.ts.map +1 -1
  37. package/dist/cf-worker/shared.js +27 -0
  38. package/dist/cf-worker/shared.js.map +1 -1
  39. package/dist/cf-worker/worker.d.ts +31 -31
  40. package/dist/cf-worker/worker.d.ts.map +1 -1
  41. package/dist/cf-worker/worker.js +27 -30
  42. package/dist/cf-worker/worker.js.map +1 -1
  43. package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
  44. package/dist/client/transport/do-rpc-client.js +11 -7
  45. package/dist/client/transport/do-rpc-client.js.map +1 -1
  46. package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
  47. package/dist/client/transport/http-rpc-client.js +10 -6
  48. package/dist/client/transport/http-rpc-client.js.map +1 -1
  49. package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
  50. package/dist/client/transport/ws-rpc-client.js +11 -11
  51. package/dist/client/transport/ws-rpc-client.js.map +1 -1
  52. package/dist/common/do-rpc-schema.d.ts +3 -3
  53. package/dist/common/do-rpc-schema.d.ts.map +1 -1
  54. package/dist/common/do-rpc-schema.js +3 -3
  55. package/dist/common/do-rpc-schema.js.map +1 -1
  56. package/dist/common/http-rpc-schema.d.ts +3 -3
  57. package/dist/common/http-rpc-schema.d.ts.map +1 -1
  58. package/dist/common/http-rpc-schema.js +3 -3
  59. package/dist/common/http-rpc-schema.js.map +1 -1
  60. package/dist/common/sync-message-types.d.ts +2 -2
  61. package/dist/common/ws-rpc-schema.d.ts +3 -3
  62. package/dist/common/ws-rpc-schema.d.ts.map +1 -1
  63. package/dist/common/ws-rpc-schema.js +3 -3
  64. package/dist/common/ws-rpc-schema.js.map +1 -1
  65. package/package.json +71 -13
  66. package/src/cf-worker/do/durable-object.ts +18 -10
  67. package/src/cf-worker/do/layer.ts +4 -3
  68. package/src/cf-worker/do/pull.ts +28 -9
  69. package/src/cf-worker/do/push.ts +29 -10
  70. package/src/cf-worker/do/sqlite.ts +1 -0
  71. package/src/cf-worker/do/sync-storage.ts +4 -2
  72. package/src/cf-worker/do/transport/do-rpc-server.ts +18 -7
  73. package/src/cf-worker/do/transport/http-rpc-server.ts +27 -21
  74. package/src/cf-worker/do/transport/ws-rpc-server.ts +40 -12
  75. package/src/cf-worker/shared.ts +89 -11
  76. package/src/cf-worker/worker.ts +64 -47
  77. package/src/client/transport/do-rpc-client.ts +20 -14
  78. package/src/client/transport/http-rpc-client.ts +19 -13
  79. package/src/client/transport/ws-rpc-client.ts +39 -36
  80. package/src/common/do-rpc-schema.ts +4 -3
  81. package/src/common/http-rpc-schema.ts +4 -3
  82. package/src/common/ws-rpc-schema.ts +4 -3
@@ -1,4 +1,4 @@
1
- import type { InvalidPullError, InvalidPushError } from '@livestore/common'
1
+ import type { UnknownError } from '@livestore/common'
2
2
  import type { CfTypes } from '@livestore/common-cf'
3
3
  import { Effect, Schema, UrlParams } from '@livestore/utils/effect'
4
4
 
@@ -7,17 +7,61 @@ import { SearchParamsSchema, SyncMessage } from '../common/mod.ts'
7
7
 
8
8
  export type Env = {}
9
9
 
10
+ /** Headers forwarded from the request to callbacks */
11
+ export type ForwardedHeaders = ReadonlyMap<string, string>
12
+
13
+ /**
14
+ * Configuration for forwarding request headers to DO callbacks.
15
+ * - `string[]`: List of header names to forward (case-insensitive)
16
+ * - `(request) => Record<string, string>`: Custom extraction function (sync)
17
+ */
18
+ export type ForwardHeadersOption = readonly string[] | ((request: CfTypes.Request) => Record<string, string>)
19
+
20
+ /** Context passed to onPush/onPull callbacks */
21
+ export type CallbackContext = {
22
+ storeId: StoreId
23
+ payload?: Schema.JsonValue
24
+ /** Headers forwarded from the request (only present if `forwardHeaders` is configured) */
25
+ headers?: ForwardedHeaders
26
+ }
27
+
10
28
  export type MakeDurableObjectClassOptions = {
11
- onPush?: (
12
- message: SyncMessage.PushRequest,
13
- context: { storeId: StoreId; payload?: Schema.JsonValue },
14
- ) => Effect.SyncOrPromiseOrEffect<void>
15
- onPushRes?: (message: SyncMessage.PushAck | InvalidPushError) => Effect.SyncOrPromiseOrEffect<void>
16
- onPull?: (
17
- message: SyncMessage.PullRequest,
18
- context: { storeId: StoreId; payload?: Schema.JsonValue },
19
- ) => Effect.SyncOrPromiseOrEffect<void>
20
- onPullRes?: (message: SyncMessage.PullResponse | InvalidPullError) => Effect.SyncOrPromiseOrEffect<void>
29
+ onPush?: (message: SyncMessage.PushRequest, context: CallbackContext) => Effect.SyncOrPromiseOrEffect<void>
30
+ onPushRes?: (message: SyncMessage.PushAck | UnknownError) => Effect.SyncOrPromiseOrEffect<void>
31
+ onPull?: (message: SyncMessage.PullRequest, context: CallbackContext) => Effect.SyncOrPromiseOrEffect<void>
32
+ onPullRes?: (message: SyncMessage.PullResponse | UnknownError) => Effect.SyncOrPromiseOrEffect<void>
33
+
34
+ /**
35
+ * Forward request headers to `onPush`/`onPull` callbacks for authentication.
36
+ *
37
+ * This enables cookie-based or header-based authentication patterns where
38
+ * you need access to request headers inside the Durable Object.
39
+ *
40
+ * @example Forward specific headers by name (case-insensitive)
41
+ * ```ts
42
+ * makeDurableObject({
43
+ * forwardHeaders: ['cookie', 'authorization'],
44
+ * onPush: async (message, { headers }) => {
45
+ * const cookie = headers?.get('cookie')
46
+ * const session = await validateSession(cookie)
47
+ * },
48
+ * })
49
+ * ```
50
+ *
51
+ * @example Custom extraction function for derived values
52
+ * ```ts
53
+ * makeDurableObject({
54
+ * forwardHeaders: (request) => ({
55
+ * 'x-user-id': request.headers.get('x-user-id') ?? '',
56
+ * 'x-session': request.headers.get('cookie')?.split('session=')[1]?.split(';')[0] ?? '',
57
+ * }),
58
+ * onPush: async (message, { headers }) => {
59
+ * const userId = headers?.get('x-user-id')
60
+ * },
61
+ * })
62
+ * ```
63
+ */
64
+ forwardHeaders?: ForwardHeadersOption
21
65
  /**
22
66
  * Storage engine for event persistence.
23
67
  * - Default: `{ _tag: 'do-sqlite' }` (Durable Object SQLite)
@@ -137,5 +181,39 @@ export const WebSocketAttachmentSchema = Schema.parseJson(
137
181
  // Different for each websocket connection
138
182
  payload: Schema.optional(Schema.JsonValue),
139
183
  pullRequestIds: Schema.Array(Schema.String),
184
+ // Headers forwarded from the initial request (via forwardHeaders option)
185
+ headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.String })),
140
186
  }),
141
187
  )
188
+
189
+ /** Helper to extract headers from a request based on the forwardHeaders option */
190
+ export const extractForwardedHeaders = (
191
+ request: CfTypes.Request,
192
+ forwardHeaders: ForwardHeadersOption | undefined,
193
+ ): Record<string, string> | undefined => {
194
+ if (forwardHeaders === undefined) {
195
+ return undefined
196
+ }
197
+
198
+ if (typeof forwardHeaders === 'function') {
199
+ return forwardHeaders(request)
200
+ }
201
+
202
+ // Array of header names - extract them case-insensitively
203
+ const result: Record<string, string> = {}
204
+ for (const name of forwardHeaders) {
205
+ const value = request.headers.get(name)
206
+ if (value !== null) {
207
+ result[name.toLowerCase()] = value
208
+ }
209
+ }
210
+ return Object.keys(result).length > 0 ? result : undefined
211
+ }
212
+
213
+ /** Convert a headers record to a ReadonlyMap */
214
+ export const headersRecordToMap = (headers: Record<string, string> | undefined): ForwardedHeaders | undefined => {
215
+ if (headers === undefined) {
216
+ return undefined
217
+ }
218
+ return new Map(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
219
+ }
@@ -1,10 +1,12 @@
1
1
  import { env as importedEnv } from 'cloudflare:workers'
2
+
2
3
  import { UnknownError } from '@livestore/common'
3
4
  import type { HelperTypes } from '@livestore/common-cf'
4
5
  import { Effect, Schema } from '@livestore/utils/effect'
6
+
5
7
  import type { CfTypes, SearchParams } from '../common/mod.ts'
6
8
  import type { CfDeclare } from './mod.ts'
7
- import { type Env, matchSyncRequest } from './shared.ts'
9
+ import { type Env, type ForwardedHeaders, matchSyncRequest } from './shared.ts'
8
10
 
9
11
  // NOTE We need to redeclare runtime types here to avoid type conflicts with the lib.dom Response type.
10
12
  declare class Response extends CfDeclare.Response {}
@@ -18,6 +20,13 @@ export type CFWorker<TEnv extends Env = Env, _T extends CfTypes.Rpc.DurableObjec
18
20
  ) => Promise<CfTypes.Response>
19
21
  }
20
22
 
23
+ /** Context passed to validatePayload callback */
24
+ export type ValidatePayloadContext = {
25
+ storeId: string
26
+ /** Request headers (raw, not filtered by forwardHeaders) */
27
+ headers: ForwardedHeaders
28
+ }
29
+
21
30
  /**
22
31
  * Options accepted by {@link makeWorker}. The Durable Object binding has to be
23
32
  * supplied explicitly so we never fall back to deprecated defaults when Cloudflare config changes.
@@ -27,11 +36,6 @@ export type MakeWorkerOptions<TEnv extends Env = Env, TSyncPayload = Schema.Json
27
36
  * Binding name of the sync Durable Object declared in wrangler config.
28
37
  */
29
38
  syncBackendBinding: HelperTypes.ExtractDurableObjectKeys<TEnv>
30
- /**
31
- * Validates the payload during WebSocket connection establishment.
32
- * Note: This runs only at connection time, not for individual push events.
33
- * For push event validation, use the `onPush` callback in the Durable Object.
34
- */
35
39
  /**
36
40
  * Optionally pass a schema to decode the client-provided payload into a typed object
37
41
  * before calling {@link validatePayload}. If omitted, the raw JSON value is forwarded.
@@ -39,9 +43,23 @@ export type MakeWorkerOptions<TEnv extends Env = Env, TSyncPayload = Schema.Json
39
43
  syncPayloadSchema?: Schema.Schema<TSyncPayload>
40
44
  /**
41
45
  * Validates the (optionally decoded) payload during WebSocket connection establishment.
42
- * If {@link syncPayloadSchema} is provided, `payload` will be of the schemas inferred type.
46
+ * If {@link syncPayloadSchema} is provided, `payload` will be of the schema's inferred type.
47
+ *
48
+ * The context includes request headers for cookie-based or header-based authentication.
49
+ *
50
+ * @example Cookie-based authentication
51
+ * ```ts
52
+ * validatePayload: async (payload, { storeId, headers }) => {
53
+ * const cookie = headers.get('cookie')
54
+ * const session = await validateSessionFromCookie(cookie)
55
+ * if (!session) throw new Error('Unauthorized')
56
+ * }
57
+ * ```
58
+ *
59
+ * Note: This runs only at connection time, not for individual push events.
60
+ * For push event validation, use the `onPush` callback in the Durable Object.
43
61
  */
44
- validatePayload?: (payload: TSyncPayload, context: { storeId: string }) => void | Promise<void>
62
+ validatePayload?: (payload: TSyncPayload, context: ValidatePayloadContext) => void | Promise<void>
45
63
  /** @default false */
46
64
  enableCORS?: boolean
47
65
  }
@@ -64,15 +82,16 @@ export const makeWorker = <
64
82
  fetch: async (request, env, _ctx) => {
65
83
  const url = new URL(request.url)
66
84
 
67
- const corsHeaders: CfTypes.HeadersInit = options.enableCORS
68
- ? {
69
- 'Access-Control-Allow-Origin': '*',
70
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
71
- 'Access-Control-Allow-Headers': request.headers.get('Access-Control-Request-Headers') ?? '*',
72
- }
73
- : {}
85
+ const corsHeaders: CfTypes.HeadersInit =
86
+ options.enableCORS === true
87
+ ? {
88
+ 'Access-Control-Allow-Origin': '*',
89
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
90
+ 'Access-Control-Allow-Headers': request.headers.get('Access-Control-Request-Headers') ?? '*',
91
+ }
92
+ : {}
74
93
 
75
- if (request.method === 'OPTIONS' && options.enableCORS) {
94
+ if (request.method === 'OPTIONS' && options.enableCORS === true) {
76
95
  return new Response(null, {
77
96
  status: 204,
78
97
  headers: corsHeaders,
@@ -117,37 +136,33 @@ export const makeWorker = <
117
136
  }
118
137
  }
119
138
 
139
+ /** Convert CF Request headers to a ForwardedHeaders map */
140
+ const requestHeadersToMap = (request: CfTypes.Request): ForwardedHeaders => {
141
+ const result = new Map<string, string>()
142
+ request.headers.forEach((value, key) => {
143
+ result.set(key.toLowerCase(), value)
144
+ })
145
+ return result
146
+ }
147
+
120
148
  /**
121
149
  * Handles LiveStore sync requests (e.g. with search params `?storeId=...&transport=...`).
122
150
  *
123
- * @example
151
+ * @example Token-based authentication
124
152
  * ```ts
125
153
  * const validatePayload = (payload: Schema.JsonValue | undefined, context: { storeId: string }) => {
126
- * console.log(`Validating connection for store: ${context.storeId}`)
127
154
  * if (payload?.authToken !== 'insecure-token-change-me') {
128
155
  * throw new Error('Invalid auth token')
129
156
  * }
130
157
  * }
158
+ * ```
131
159
  *
132
- * export default {
133
- * fetch: async (request, env, ctx) => {
134
- * const searchParams = matchSyncRequest(request)
135
- *
136
- * // Is LiveStore sync request
137
- * if (searchParams !== undefined) {
138
- * return handleSyncRequest({
139
- * request,
140
- * searchParams,
141
- * env,
142
- * ctx,
143
- * syncBackendBinding: 'SYNC_BACKEND_DO',
144
- * headers: {},
145
- * validatePayload,
146
- * })
147
- * }
148
- *
149
- * return new Response('Invalid path', { status: 400 })
150
- * }
160
+ * @example Cookie-based authentication
161
+ * ```ts
162
+ * const validatePayload = async (payload: Schema.JsonValue | undefined, { storeId, headers }) => {
163
+ * const cookie = headers.get('cookie')
164
+ * const session = await validateSessionFromCookie(cookie)
165
+ * if (!session) throw new Error('Unauthorized')
151
166
  * }
152
167
  * ```
153
168
  *
@@ -180,6 +195,9 @@ export const handleSyncRequest = <
180
195
  }): Promise<CfTypes.Response> =>
181
196
  Effect.gen(function* () {
182
197
  if (validatePayload !== undefined) {
198
+ // Convert request headers to a Map for the validation context
199
+ const requestHeaders = requestHeadersToMap(request)
200
+
183
201
  // Always decode with the supplied schema when present, even if payload is undefined.
184
202
  // This ensures required payloads are enforced by the schema.
185
203
  if (syncPayloadSchema !== undefined) {
@@ -187,26 +205,25 @@ export const handleSyncRequest = <
187
205
  if (decodedEither._tag === 'Left') {
188
206
  const message = decodedEither.left.toString()
189
207
  console.error('Invalid payload (decode failed)', message)
190
- return new Response(message, { status: 400, headers })
208
+ return new Response(message, { status: 400, ...(headers !== undefined ? { headers } : {}) })
191
209
  }
192
210
 
193
211
  const result = yield* Effect.promise(async () =>
194
- validatePayload(decodedEither.right as TSyncPayload, { storeId }),
212
+ validatePayload(decodedEither.right, { storeId, headers: requestHeaders }),
195
213
  ).pipe(UnknownError.mapToUnknownError, Effect.either)
196
214
 
197
215
  if (result._tag === 'Left') {
198
216
  console.error('Invalid payload (validation failed)', result.left)
199
- return new Response(result.left.toString(), { status: 400, headers })
217
+ return new Response(result.left.toString(), { status: 400, ...(headers !== undefined ? { headers } : {}) })
200
218
  }
201
219
  } else {
202
- const result = yield* Effect.promise(async () => validatePayload(payload as TSyncPayload, { storeId })).pipe(
203
- UnknownError.mapToUnknownError,
204
- Effect.either,
205
- )
220
+ const result = yield* Effect.promise(async () =>
221
+ validatePayload(payload as TSyncPayload, { storeId, headers: requestHeaders }),
222
+ ).pipe(UnknownError.mapToUnknownError, Effect.either)
206
223
 
207
224
  if (result._tag === 'Left') {
208
225
  console.error('Invalid payload (validation failed)', result.left)
209
- return new Response(result.left.toString(), { status: 400, headers })
226
+ return new Response(result.left.toString(), { status: 400, ...(headers !== undefined ? { headers } : {}) })
210
227
  }
211
228
  }
212
229
  }
@@ -218,7 +235,7 @@ export const handleSyncRequest = <
218
235
  `Failed dependency: Required Durable Object binding '${syncBackendBinding as string}' not available`,
219
236
  {
220
237
  status: 424,
221
- headers,
238
+ ...(headers !== undefined ? { headers } : {}),
222
239
  },
223
240
  )
224
241
  }
@@ -235,7 +252,7 @@ export const handleSyncRequest = <
235
252
  if (transport === 'ws' && (upgradeHeader === null || upgradeHeader !== 'websocket')) {
236
253
  return new Response('Durable Object expected Upgrade: websocket', {
237
254
  status: 426,
238
- headers,
255
+ ...(headers !== undefined ? { headers } : {}),
239
256
  })
240
257
  }
241
258
 
@@ -1,4 +1,4 @@
1
- import { InvalidPullError, InvalidPushError, SyncBackend, UnknownError } from '@livestore/common'
1
+ import { SyncBackend, UnknownError } from '@livestore/common'
2
2
  import { splitChunkBySize } from '@livestore/common/sync'
3
3
  import { type CfTypes, layerProtocolDurableObject } from '@livestore/common-cf'
4
4
  import { omit, shouldNeverHappen } from '@livestore/utils'
@@ -15,6 +15,7 @@ import {
15
15
  Stream,
16
16
  SubscriptionRef,
17
17
  } from '@livestore/utils/effect'
18
+
18
19
  import type { SyncBackendRpcInterface } from '../../cf-worker/shared.ts'
19
20
  import { MAX_DO_RPC_REQUEST_BYTES, MAX_PUSH_EVENTS_PER_REQUEST } from '../../common/constants.ts'
20
21
  import { SyncDoRpc } from '../../common/do-rpc-schema.ts'
@@ -73,9 +74,9 @@ export const makeDoRpcSync =
73
74
  })),
74
75
  ),
75
76
  storeId,
76
- rpcContext: options?.live ? { callerContext: durableObjectContext } : undefined,
77
+ rpcContext: options?.live === true ? { callerContext: durableObjectContext } : undefined,
77
78
  }).pipe(
78
- options?.live
79
+ options?.live === true
79
80
  ? Stream.concatWithLastElement((res) =>
80
81
  Effect.gen(function* () {
81
82
  if (res._tag === 'None')
@@ -93,12 +94,16 @@ export const makeDoRpcSync =
93
94
  : identity,
94
95
  Stream.tap((res) => backendIdHelper.lazySet(res.backendId)),
95
96
  Stream.map((res) => omit(res, ['backendId'])),
96
- Stream.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
97
+ Stream.mapError((cause) =>
98
+ cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
99
+ ? cause
100
+ : new UnknownError({ cause }),
101
+ ),
97
102
  Stream.withSpan('rpc-sync-client:pull'),
98
103
  )
99
104
 
100
- const push: SyncBackend.SyncBackend<{ createdAt: string }>['push'] = (batch) =>
101
- Effect.gen(function* () {
105
+ const push: SyncBackend.SyncBackend<{ createdAt: string }>['push'] = Effect.fn('rpc-sync-client:push')(
106
+ function* (batch) {
102
107
  if (batch.length === 0) {
103
108
  return
104
109
  }
@@ -114,19 +119,20 @@ export const makeDoRpcSync =
114
119
  backendId,
115
120
  }),
116
121
  }),
117
- Effect.mapError((cause) => new InvalidPushError({ cause: new UnknownError({ cause }) })),
122
+ Effect.mapError((cause) => new UnknownError({ cause })),
118
123
  )
119
124
 
120
125
  for (const chunk of Chunk.toReadonlyArray(batchChunks)) {
121
126
  const chunkArray = Chunk.toReadonlyArray(chunk)
122
127
  yield* rpcClient.SyncDoRpc.Push({ batch: chunkArray, storeId, backendId })
123
128
  }
124
- }).pipe(
125
- Effect.mapError((cause) =>
126
- cause._tag === 'InvalidPushError' ? cause : InvalidPushError.make({ cause: new UnknownError({ cause }) }),
127
- ),
128
- Effect.withSpan('rpc-sync-client:push'),
129
- )
129
+ },
130
+ Effect.mapError((cause) =>
131
+ cause._tag === 'UnknownError' || cause._tag === 'ServerAheadError' || cause._tag === 'BackendIdMismatchError'
132
+ ? cause
133
+ : new UnknownError({ cause }),
134
+ ),
135
+ )
130
136
 
131
137
  const ping: SyncBackend.SyncBackend<{ createdAt: string }>['ping'] = rpcClient.SyncDoRpc.Ping({
132
138
  storeId,
@@ -170,7 +176,7 @@ export const makeDoRpcSync =
170
176
  export const handleSyncUpdateRpc = (payload: unknown) =>
171
177
  Effect.gen(function* () {
172
178
  const decodedPayload = yield* Schema.decodeUnknown(ResponseChunkEncoded)(payload)
173
- const decoded = yield* Schema.decodeUnknown(SyncMessage.PullResponse)(decodedPayload.values[0]!)
179
+ const decoded = yield* Schema.decodeUnknown(SyncMessage.PullResponse)(decodedPayload.values[0])
174
180
 
175
181
  const pullStreamMailbox = requestIdMailboxMap.get(decodedPayload.requestId)
176
182
 
@@ -1,4 +1,4 @@
1
- import { InvalidPullError, InvalidPushError, SyncBackend, UnknownError } from '@livestore/common'
1
+ import { SyncBackend, UnknownError } from '@livestore/common'
2
2
  import type { EventSequenceNumber } from '@livestore/common/schema'
3
3
  import { splitChunkBySize } from '@livestore/common/sync'
4
4
  import { omit } from '@livestore/utils'
@@ -19,6 +19,7 @@ import {
19
19
  SubscriptionRef,
20
20
  UrlParams,
21
21
  } from '@livestore/utils/effect'
22
+
22
23
  import { MAX_HTTP_REQUEST_BYTES, MAX_PUSH_EVENTS_PER_REQUEST } from '../../common/constants.ts'
23
24
  import { SyncHttpRpc } from '../../common/http-rpc-schema.ts'
24
25
  import { SearchParamsSchema } from '../../common/mod.ts'
@@ -134,7 +135,7 @@ export const makeHttpSync =
134
135
  payload,
135
136
  cursor: mapCursor(cursor),
136
137
  }).pipe(
137
- options?.live
138
+ options?.live === true
138
139
  ? // Phase 2: Simulate `live` pull by polling for new events
139
140
  Stream.concatWithLastElement((lastElement) => {
140
141
  const initialPhase2Cursor = lastElement.pipe(
@@ -166,14 +167,18 @@ export const makeHttpSync =
166
167
  : identity,
167
168
  Stream.tap((res) => backendIdHelper.lazySet(res.backendId)),
168
169
  Stream.map((res) => omit(res, ['backendId'])),
169
- Stream.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
170
+ Stream.mapError((cause) =>
171
+ cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
172
+ ? cause
173
+ : new UnknownError({ cause }),
174
+ ),
170
175
  Stream.withSpan('http-sync-client:pull'),
171
176
  )
172
177
 
173
178
  const pushSemaphore = yield* Effect.makeSemaphore(1)
174
179
 
175
- const push: SyncBackend.SyncBackend<SyncMetadata>['push'] = (batch) =>
176
- Effect.gen(function* () {
180
+ const push: SyncBackend.SyncBackend<SyncMetadata>['push'] = Effect.fn('http-sync-client:push')(
181
+ function* (batch) {
177
182
  if (batch.length === 0) {
178
183
  return
179
184
  }
@@ -190,20 +195,21 @@ export const makeHttpSync =
190
195
  backendId,
191
196
  }),
192
197
  }),
193
- Effect.mapError((cause) => new InvalidPushError({ cause: new UnknownError({ cause }) })),
198
+ Effect.mapError((cause) => new UnknownError({ cause })),
194
199
  )
195
200
 
196
201
  for (const chunk of Chunk.toReadonlyArray(batchChunks)) {
197
202
  const chunkArray = Chunk.toReadonlyArray(chunk)
198
203
  yield* rpcClient.SyncHttpRpc.Push({ storeId, payload, batch: chunkArray, backendId })
199
204
  }
200
- }).pipe(
201
- pushSemaphore.withPermits(1),
202
- Effect.mapError((cause) =>
203
- cause._tag === 'InvalidPushError' ? cause : new InvalidPushError({ cause: new UnknownError({ cause }) }),
204
- ),
205
- Effect.withSpan('http-sync-client:push'),
206
- )
205
+ },
206
+ pushSemaphore.withPermits(1),
207
+ Effect.mapError((cause) =>
208
+ cause._tag === 'UnknownError' || cause._tag === 'ServerAheadError' || cause._tag === 'BackendIdMismatchError'
209
+ ? cause
210
+ : new UnknownError({ cause }),
211
+ ),
212
+ )
207
213
 
208
214
  return SyncBackend.of({
209
215
  connect,
@@ -1,4 +1,4 @@
1
- import { InvalidPullError, InvalidPushError, IsOfflineError, SyncBackend, UnknownError } from '@livestore/common'
1
+ import { IsOfflineError, SyncBackend, UnknownError } from '@livestore/common'
2
2
  import type { LiveStoreEvent } from '@livestore/common/schema'
3
3
  import { splitChunkBySize } from '@livestore/common/sync'
4
4
  import { omit } from '@livestore/utils'
@@ -19,6 +19,7 @@ import {
19
19
  UrlParams,
20
20
  } from '@livestore/utils/effect'
21
21
  import type { WebSocket } from '@livestore/utils/effect/browser'
22
+
22
23
  import { MAX_PUSH_EVENTS_PER_REQUEST, MAX_WS_MESSAGE_BYTES } from '../../common/constants.ts'
23
24
  import { SearchParamsSchema } from '../../common/mod.ts'
24
25
  import type { SyncMetadata } from '../../common/sync-message-types.ts'
@@ -95,7 +96,10 @@ export const makeWsSync =
95
96
 
96
97
  const ProtocolLive = RpcClient.layerProtocolSocketWithIsConnected({
97
98
  isConnected,
98
- retryTransientErrors: Schedule.fixed(1000),
99
+ retryTransientErrors: Schedule.exponential('1 seconds').pipe(
100
+ Schedule.union(Schedule.fixed('30 seconds')),
101
+ Schedule.jittered,
102
+ ),
99
103
  pingSchedule: Schedule.once.pipe(Schedule.andThen(Schedule.fixed(pingInterval))),
100
104
  url: wsUrl,
101
105
  }).pipe(
@@ -138,55 +142,54 @@ export const makeWsSync =
138
142
  backendId: backendIdHelper.get().pipe(Option.getOrThrow),
139
143
  })),
140
144
  ),
141
- live: options?.live ?? false,
145
+ live: options?.live === true,
142
146
  }).pipe(
143
147
  Stream.tap((res) => backendIdHelper.lazySet(res.backendId)),
144
148
  Stream.map((res) => omit(res, ['backendId'])),
145
149
  Stream.mapError((cause) =>
146
- cause._tag === 'RpcClientError' && Socket.isSocketError(cause.cause)
150
+ cause._tag === 'RpcClientError' && Socket.isSocketError(cause.cause) === true
147
151
  ? new IsOfflineError({ cause: cause.cause })
148
- : cause._tag === 'InvalidPullError'
152
+ : cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
149
153
  ? cause
150
- : InvalidPullError.make({ cause }),
154
+ : new UnknownError({ cause }),
151
155
  ),
152
156
  Stream.withSpan('pull'),
153
157
  ),
154
158
 
155
- push: (batch) =>
156
- Effect.gen(function* () {
157
- if (batch.length === 0) return
159
+ push: Effect.fn('push')(function* (batch) {
160
+ if (batch.length === 0) return
158
161
 
159
- const encodePayload = (batch: ReadonlyArray<LiveStoreEvent.Global.Encoded>) => ({
162
+ const encodePayload = (batch: ReadonlyArray<LiveStoreEvent.Global.Encoded>) => ({
163
+ storeId,
164
+ payload,
165
+ batch,
166
+ backendId: backendIdHelper.get(),
167
+ })
168
+
169
+ const chunksChunk = yield* Chunk.fromIterable(batch).pipe(
170
+ splitChunkBySize({
171
+ maxItems: MAX_PUSH_EVENTS_PER_REQUEST,
172
+ maxBytes: MAX_WS_MESSAGE_BYTES,
173
+ encode: encodePayload,
174
+ }),
175
+ Effect.mapError((cause) => new UnknownError({ cause })),
176
+ )
177
+
178
+ for (const sub of chunksChunk) {
179
+ yield* rpcClient.SyncWsRpc.Push({
160
180
  storeId,
161
181
  payload,
162
- batch,
182
+ batch: Chunk.toReadonlyArray(sub),
163
183
  backendId: backendIdHelper.get(),
164
- })
165
-
166
- const chunksChunk = yield* Chunk.fromIterable(batch).pipe(
167
- splitChunkBySize({
168
- maxItems: MAX_PUSH_EVENTS_PER_REQUEST,
169
- maxBytes: MAX_WS_MESSAGE_BYTES,
170
- encode: encodePayload,
171
- }),
172
- Effect.mapError((cause) => new InvalidPushError({ cause: new UnknownError({ cause }) })),
184
+ }).pipe(
185
+ Effect.mapError((cause) =>
186
+ cause._tag === 'UnknownError' || cause._tag === 'ServerAheadError' || cause._tag === 'BackendIdMismatchError'
187
+ ? cause
188
+ : new UnknownError({ cause }),
189
+ ),
173
190
  )
174
-
175
- for (const sub of chunksChunk) {
176
- yield* rpcClient.SyncWsRpc.Push({
177
- storeId,
178
- payload,
179
- batch: Chunk.toReadonlyArray(sub),
180
- backendId: backendIdHelper.get(),
181
- }).pipe(
182
- Effect.mapError((cause) =>
183
- cause._tag === 'InvalidPushError'
184
- ? cause
185
- : new InvalidPushError({ cause: new UnknownError({ cause }) }),
186
- ),
187
- )
188
- }
189
- }).pipe(Effect.withSpan('push')),
191
+ }
192
+ }),
190
193
  ping,
191
194
  metadata: {
192
195
  name: '@livestore/cf-sync',
@@ -1,5 +1,6 @@
1
- import { InvalidPullError, InvalidPushError } from '@livestore/common'
1
+ import { BackendIdMismatchError, ServerAheadError, UnknownError } from '@livestore/common'
2
2
  import { Rpc, RpcGroup, Schema } from '@livestore/utils/effect'
3
+
3
4
  import * as SyncMessage from './sync-message-types.ts'
4
5
 
5
6
  const commonPayloadFields = {
@@ -34,7 +35,7 @@ export class SyncDoRpc extends RpcGroup.make(
34
35
  rpcRequestId: Schema.String,
35
36
  ...SyncMessage.PullResponse.fields,
36
37
  }),
37
- error: InvalidPullError,
38
+ error: Schema.Union(UnknownError, BackendIdMismatchError),
38
39
  stream: true,
39
40
  }),
40
41
  Rpc.make('SyncDoRpc.Push', {
@@ -43,7 +44,7 @@ export class SyncDoRpc extends RpcGroup.make(
43
44
  ...commonPayloadFields,
44
45
  },
45
46
  success: SyncMessage.PushAck,
46
- error: InvalidPushError,
47
+ error: Schema.Union(UnknownError, ServerAheadError, BackendIdMismatchError),
47
48
  }),
48
49
  Rpc.make('SyncDoRpc.Ping', {
49
50
  payload: {
@@ -1,5 +1,6 @@
1
- import { InvalidPullError, InvalidPushError, UnknownError } from '@livestore/common'
1
+ import { BackendIdMismatchError, ServerAheadError, UnknownError } from '@livestore/common'
2
2
  import { Rpc, RpcGroup, Schema } from '@livestore/utils/effect'
3
+
3
4
  import * as SyncMessage from './sync-message-types.ts'
4
5
 
5
6
  /**
@@ -17,7 +18,7 @@ export class SyncHttpRpc extends RpcGroup.make(
17
18
  ...SyncMessage.PullRequest.fields,
18
19
  }),
19
20
  success: SyncMessage.PullResponse,
20
- error: InvalidPullError,
21
+ error: Schema.Union(UnknownError, BackendIdMismatchError),
21
22
  stream: true,
22
23
  }),
23
24
  Rpc.make('SyncHttpRpc.Push', {
@@ -27,7 +28,7 @@ export class SyncHttpRpc extends RpcGroup.make(
27
28
  ...SyncMessage.PushRequest.fields,
28
29
  }),
29
30
  success: SyncMessage.PushAck,
30
- error: InvalidPushError,
31
+ error: Schema.Union(UnknownError, ServerAheadError, BackendIdMismatchError),
31
32
  }),
32
33
  Rpc.make('SyncHttpRpc.Ping', {
33
34
  payload: Schema.Struct({