@livestore/sync-s2 0.4.0-dev.8 → 0.4.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 (47) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/api-schema.d.ts.map +1 -1
  3. package/dist/api-schema.js +1 -1
  4. package/dist/api-schema.js.map +1 -1
  5. package/dist/api-schema.test.js +1 -1
  6. package/dist/api-schema.test.js.map +1 -1
  7. package/dist/decode.d.ts +1 -1
  8. package/dist/decode.d.ts.map +1 -1
  9. package/dist/decode.js +1 -1
  10. package/dist/decode.js.map +1 -1
  11. package/dist/http-client-generated.d.ts +21 -21
  12. package/dist/http-client-generated.d.ts.map +1 -1
  13. package/dist/http-client-generated.js +64 -22
  14. package/dist/http-client-generated.js.map +1 -1
  15. package/dist/limits.d.ts +41 -0
  16. package/dist/limits.d.ts.map +1 -0
  17. package/dist/limits.js +90 -0
  18. package/dist/limits.js.map +1 -0
  19. package/dist/limits.test.d.ts +2 -0
  20. package/dist/limits.test.d.ts.map +1 -0
  21. package/dist/limits.test.js +30 -0
  22. package/dist/limits.test.js.map +1 -0
  23. package/dist/make-s2-url.d.ts.map +1 -1
  24. package/dist/make-s2-url.js.map +1 -1
  25. package/dist/mod.d.ts +1 -0
  26. package/dist/mod.d.ts.map +1 -1
  27. package/dist/mod.js +1 -0
  28. package/dist/mod.js.map +1 -1
  29. package/dist/s2-proxy-helpers.d.ts +26 -22
  30. package/dist/s2-proxy-helpers.d.ts.map +1 -1
  31. package/dist/s2-proxy-helpers.js +35 -33
  32. package/dist/s2-proxy-helpers.js.map +1 -1
  33. package/dist/sync-provider.d.ts +4 -4
  34. package/dist/sync-provider.d.ts.map +1 -1
  35. package/dist/sync-provider.js +49 -17
  36. package/dist/sync-provider.js.map +1 -1
  37. package/package.json +76 -13
  38. package/src/api-schema.test.ts +3 -1
  39. package/src/api-schema.ts +2 -1
  40. package/src/decode.ts +4 -3
  41. package/src/http-client-generated.ts +74 -53
  42. package/src/limits.test.ts +44 -0
  43. package/src/limits.ts +135 -0
  44. package/src/make-s2-url.ts +1 -0
  45. package/src/mod.ts +1 -0
  46. package/src/s2-proxy-helpers.ts +57 -47
  47. package/src/sync-provider.ts +78 -31
@@ -4,7 +4,9 @@
4
4
  */
5
5
 
6
6
  import type { LiveStoreEvent } from '@livestore/livestore'
7
+
7
8
  import type { PullArgs } from './api-schema.ts'
9
+ import { chunkEventsForS2 } from './limits.ts'
8
10
  import { makeS2StreamName } from './make-s2-url.ts'
9
11
 
10
12
  /** Configuration for S2 connections */
@@ -15,8 +17,20 @@ export interface S2Config {
15
17
  accountBase?: string
16
18
  /** @default 'https://{basin}.b.aws.s2.dev/v1' */
17
19
  basinBase?: string
20
+ /**
21
+ * When true, adds `S2-Basin` header to requests. This is required for s2-lite
22
+ * (the open-source self-hosted S2) which uses header-based basin routing instead
23
+ * of subdomain-based routing used by hosted S2.
24
+ * @see https://github.com/s2-streamstore/s2-lite
25
+ */
26
+ lite?: boolean
18
27
  }
19
28
 
29
+ export const isLiteMode = (config: S2Config): boolean => config.lite === true
30
+
31
+ const getBasinHeader = (config: S2Config): Record<string, string> =>
32
+ isLiteMode(config) === true ? { 's2-basin': config.basin } : {}
33
+
20
34
  // URL construction helpers
21
35
  export const getBasinUrl = (config: S2Config, path: string): string => {
22
36
  const base = config.basinBase ?? `https://${config.basin}.b.aws.s2.dev/v1`
@@ -31,10 +45,10 @@ export const getAccountUrl = (config: S2Config, path: string): string => {
31
45
  export const getStreamRecordsUrl = (
32
46
  config: S2Config,
33
47
  stream: string,
34
- params?: { seq_num?: number; count?: number; clamp?: boolean },
48
+ params?: { seq_num?: number; count?: number; clamp?: boolean; wait?: number },
35
49
  ): string => {
36
50
  const base = getBasinUrl(config, `/streams/${encodeURIComponent(stream)}/records`)
37
- if (!params) return base
51
+ if (params == null) return base
38
52
 
39
53
  const searchParams = new URLSearchParams()
40
54
  /** seq_num - The sequence number to start from. See: https://docs.s2.dev/api#seq_num */
@@ -43,8 +57,11 @@ export const getStreamRecordsUrl = (
43
57
  if (params.count !== undefined) searchParams.append('count', params.count.toString())
44
58
  /** clamp - Whether to clamp the response to the requested count. See: https://docs.s2.dev/api#clamp */
45
59
  if (params.clamp !== undefined) searchParams.append('clamp', params.clamp.toString())
60
+ /** wait - How long to wait for new records before returning. See: https://docs.s2.dev/api#wait */
61
+ if (params.wait !== undefined) searchParams.append('wait', params.wait.toString())
46
62
 
47
- return searchParams.toString() ? `${base}?${searchParams}` : base
63
+ const searchParamsString = searchParams.toString()
64
+ return searchParamsString.length > 0 ? `${base}?${searchParams}` : base
48
65
  }
49
66
 
50
67
  // Header helpers
@@ -52,14 +69,16 @@ export const getAuthHeaders = (token: string): Record<string, string> => ({
52
69
  Authorization: `Bearer ${token}`,
53
70
  })
54
71
 
55
- export const getSSEHeaders = (token: string): Record<string, string> => ({
56
- ...getAuthHeaders(token),
72
+ export const getSSEHeaders = (config: S2Config): Record<string, string> => ({
73
+ ...getAuthHeaders(config.token),
74
+ ...getBasinHeader(config),
57
75
  accept: 'text/event-stream',
58
76
  's2-format': 'raw',
59
77
  })
60
78
 
61
- export const getPushHeaders = (token: string): Record<string, string> => ({
62
- ...getAuthHeaders(token),
79
+ export const getPushHeaders = (config: S2Config): Record<string, string> => ({
80
+ ...getAuthHeaders(config.token),
81
+ ...getBasinHeader(config),
63
82
  'content-type': 'application/json',
64
83
  's2-format': 'raw',
65
84
  })
@@ -86,6 +105,7 @@ export const ensureStream = async (config: S2Config, stream: string): Promise<vo
86
105
  method: 'POST',
87
106
  headers: {
88
107
  ...getAuthHeaders(config.token),
108
+ ...getBasinHeader(config),
89
109
  'content-type': 'application/json',
90
110
  },
91
111
  body: JSON.stringify({ stream }),
@@ -111,42 +131,50 @@ export const buildPullRequest = ({
111
131
  // cursor points to last processed record, seq_num needs to be the next record
112
132
  const seq_num = args.s2SeqNum === 'from-start' ? 0 : args.s2SeqNum + 1
113
133
 
114
- if (args.live) {
134
+ if (args.live === true) {
115
135
  const url = getStreamRecordsUrl(config, streamName, { seq_num, clamp: true })
116
- return { url, headers: getSSEHeaders(config.token) }
136
+ return { url, headers: getSSEHeaders(config) }
117
137
  } else {
118
- // Non-live pulls also stream over SSE: we request a very large `count` and
119
- // rely on S2 closing the stream once the page tail is reached. This keeps
120
- // both live and paged reads on the same transport while still giving us a
121
- // natural end-of-stream signal.
122
- const url = getStreamRecordsUrl(config, streamName, { seq_num, count: Number.MAX_SAFE_INTEGER, clamp: true })
123
- return { url, headers: getSSEHeaders(config.token) }
138
+ // Non-live pulls also stream over SSE. We ask S2 to return immediately when
139
+ // the tail is reached by setting wait=0 which gives us an explicit
140
+ // end-of-stream without requesting an arbitrarily large page size.
141
+ const url = getStreamRecordsUrl(config, streamName, { seq_num, wait: 0, clamp: true })
142
+ return { url, headers: getSSEHeaders(config) }
124
143
  }
125
144
  }
126
145
 
127
- export const buildPushRequest = ({
146
+ export interface S2PushRequest {
147
+ readonly url: string
148
+ readonly method: 'POST'
149
+ readonly headers: Record<string, string>
150
+ readonly body: string
151
+ }
152
+
153
+ /**
154
+ * Builds one or more append requests against S2. The helper applies the
155
+ * documented 1 MiB / 1000-record limits via `chunkEventsForS2`, so callers
156
+ * receive a request per compliant chunk instead of hitting 413 responses at
157
+ * runtime.
158
+ */
159
+ export const buildPushRequests = ({
128
160
  config,
129
161
  storeId,
130
162
  batch,
131
163
  }: {
132
164
  config: S2Config
133
165
  storeId: string
134
- batch: readonly LiveStoreEvent.AnyEncodedGlobal[]
135
- }): {
136
- url: string
137
- method: 'POST'
138
- headers: Record<string, string>
139
- /** JSON-encoded batch */
140
- body: string
141
- } => {
166
+ batch: readonly LiveStoreEvent.Global.Encoded[]
167
+ }): ReadonlyArray<S2PushRequest> => {
142
168
  const streamName = makeS2StreamName(storeId)
143
169
  const url = getBasinUrl(config, `/streams/${encodeURIComponent(streamName)}/records`)
144
- return {
170
+ const chunks = chunkEventsForS2(batch)
171
+
172
+ return chunks.map((chunk) => ({
145
173
  url,
146
- method: 'POST',
147
- headers: getPushHeaders(config.token),
148
- body: JSON.stringify(formatBatchForS2(batch)),
149
- }
174
+ method: 'POST' as const,
175
+ headers: getPushHeaders(config),
176
+ body: JSON.stringify({ records: chunk.records }),
177
+ }))
150
178
  }
151
179
 
152
180
  // Response helpers
@@ -176,21 +204,3 @@ export const errorResponse = (message: string, status = 500): Response => {
176
204
  headers: { 'content-type': 'application/json' },
177
205
  })
178
206
  }
179
-
180
- // Batch formatting helper
181
- export const formatBatchForS2 = (
182
- batch: readonly LiveStoreEvent.AnyEncodedGlobal[],
183
- ): { records: { body: string }[] } => {
184
- return {
185
- records: batch.map((ev) => ({ body: JSON.stringify(ev) })),
186
- }
187
- }
188
-
189
- export const asCurl = (request: { url: string; method: string; headers: Record<string, string>; body?: string }) => {
190
- const url = request.url
191
- const method = request.method
192
- const headers = Object.entries(request.headers).map(([key, value]) => `-H "${key}: ${value}"`)
193
- const body = request.body
194
- const headersStr = headers.join(' ')
195
- return `curl -X ${method} ${url} ${headersStr} ${body ? `-d '${body}'` : ''}`
196
- }
@@ -28,10 +28,10 @@
28
28
  * DO NOT couple these systems together or assume 1:1 correspondence.
29
29
  *
30
30
  * Errors
31
- * - push → InvalidPushError on non‑2xx; pull → InvalidPullError on non‑2xx; ping/connect map timeouts to offline.
31
+ * - push → UnknownError on non‑2xx; pull → UnknownError on non‑2xx; ping/connect map timeouts to offline.
32
32
  * - The proxy should surface helpful status codes and error bodies.
33
33
  */
34
- import { InvalidPullError, InvalidPushError, SyncBackend, UnexpectedError } from '@livestore/common'
34
+ import { SyncBackend, UnknownError } from '@livestore/common'
35
35
  import type { EventSequenceNumber } from '@livestore/common/schema'
36
36
  import { shouldNeverHappen } from '@livestore/utils'
37
37
  import {
@@ -47,9 +47,11 @@ import {
47
47
  Stream,
48
48
  SubscriptionRef,
49
49
  } from '@livestore/utils/effect'
50
+
50
51
  import * as ApiSchema from './api-schema.ts'
51
52
  import { decodeReadBatch } from './decode.ts'
52
53
  import * as HttpClientGenerated from './http-client-generated.ts'
54
+ import { chunkEventsForS2, S2LimitExceededError } from './limits.ts'
53
55
  import type { SyncMetadata } from './types.ts'
54
56
 
55
57
  export interface SyncS2Options {
@@ -70,14 +72,26 @@ export interface SyncS2Options {
70
72
  }
71
73
  retry?: {
72
74
  /** Custom retry schedule for non-live pulls (default: 2 recurs, 100ms spaced) */
73
- pull?: Schedule.Schedule<number, InvalidPullError>
75
+ pull?: Schedule.Schedule<number, UnknownError>
74
76
  /** Custom retry schedule for pushes (default: 2 recurs, 100ms spaced) */
75
- push?: Schedule.Schedule<number, InvalidPushError>
77
+ push?: Schedule.Schedule<number, UnknownError>
76
78
  }
77
79
  }
78
80
 
79
81
  export const defaultRetry = Schedule.compose(Schedule.recurs(2), Schedule.spaced(100))
80
82
 
83
+ const getBrowserOrigin = () => {
84
+ if (typeof globalThis !== 'object' || globalThis === null || !('location' in globalThis)) {
85
+ return undefined
86
+ }
87
+
88
+ const { location } = globalThis as typeof globalThis & {
89
+ location?: { origin?: unknown } | undefined
90
+ }
91
+
92
+ return typeof location?.origin === 'string' ? location.origin : undefined
93
+ }
94
+
81
95
  export const makeSyncBackend =
82
96
  ({ endpoint, ping: pingOptions, retry }: SyncS2Options): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
83
97
  ({ storeId, payload }) =>
@@ -89,9 +103,9 @@ export const makeSyncBackend =
89
103
 
90
104
  const httpClient = yield* HttpClient.HttpClient
91
105
 
106
+ const browserOrigin = getBrowserOrigin()
92
107
  const pullEndpointHasSameOrigin =
93
- pullEndpoint.startsWith('/') ||
94
- (globalThis.location !== undefined && globalThis.location.origin === new URL(pullEndpoint).origin)
108
+ pullEndpoint.startsWith('/') || (browserOrigin !== undefined && browserOrigin === new URL(pullEndpoint).origin)
95
109
 
96
110
  const pingTimeout = pingOptions?.requestTimeout ?? 10_000
97
111
 
@@ -99,7 +113,7 @@ export const makeSyncBackend =
99
113
  yield* httpClient.pipe(HttpClient.filterStatusOk).head(pingEndpoint)
100
114
  yield* SubscriptionRef.set(isConnected, true)
101
115
  }).pipe(
102
- UnexpectedError.mapToUnexpectedError,
116
+ UnknownError.mapToUnknownError,
103
117
  Effect.timeout(pingTimeout),
104
118
  Effect.catchTag('TimeoutException', () => SubscriptionRef.set(isConnected, false)),
105
119
  )
@@ -110,17 +124,16 @@ export const makeSyncBackend =
110
124
  }
111
125
 
112
126
  // No need to connect if the pull endpoint has the same origin as the current page
113
- const connect: SyncBackend.SyncBackend<SyncMetadata>['connect'] = pullEndpointHasSameOrigin
114
- ? Effect.void
115
- : ping.pipe(UnexpectedError.mapToUnexpectedError)
127
+ const connect: SyncBackend.SyncBackend<SyncMetadata>['connect'] =
128
+ pullEndpointHasSameOrigin === true ? Effect.void : ping.pipe(UnknownError.mapToUnknownError)
116
129
 
117
130
  const runPullSse = (
118
131
  cursor: Option.Option<{
119
- eventSequenceNumber: EventSequenceNumber.GlobalEventSequenceNumber
132
+ eventSequenceNumber: EventSequenceNumber.Global.Type
120
133
  metadata: Option.Option<SyncMetadata>
121
134
  }>,
122
135
  live: boolean,
123
- ): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>, InvalidPullError> => {
136
+ ): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>, UnknownError> => {
124
137
  // Extract S2 seqNum from metadata for SSE cursor
125
138
  const s2SeqNum = cursor.pipe(
126
139
  Option.flatMap((_) => _.metadata),
@@ -144,7 +157,7 @@ export const makeSyncBackend =
144
157
  const evt = msg.event.toLowerCase()
145
158
  if (evt === 'ping') return Option.none()
146
159
  if (evt === 'error') {
147
- return yield* new InvalidPullError({ cause: new Error(`SSE error: ${msg.data}`) })
160
+ return yield* new UnknownError({ cause: new Error(`SSE error: ${msg.data}`) })
148
161
  }
149
162
  if (evt === 'batch') {
150
163
  const readBatch = yield* Schema.decode(Schema.parseJson(HttpClientGenerated.ReadBatch))(msg.data)
@@ -176,28 +189,30 @@ export const makeSyncBackend =
176
189
  }),
177
190
  ),
178
191
  Stream.filterMap((_) => _), // filter out Option.none()
179
- Stream.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : new InvalidPullError({ cause }))),
192
+ Stream.mapError((cause) =>
193
+ cause._tag === 'UnknownError' ? cause : new UnknownError({ cause }),
194
+ ),
180
195
  Stream.retry(retry?.pull ?? defaultRetry),
181
196
  )
182
197
  }
183
198
 
184
199
  const ssePull = (
185
200
  startCursor: Option.Option<{
186
- eventSequenceNumber: EventSequenceNumber.GlobalEventSequenceNumber
201
+ eventSequenceNumber: EventSequenceNumber.Global.Type
187
202
  metadata: Option.Option<SyncMetadata>
188
203
  }>,
189
- ): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>, InvalidPullError> => {
204
+ ): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>, UnknownError> => {
190
205
  const computeNextCursor = (
191
206
  lastItem: Option.Option<SyncBackend.PullResItem<SyncMetadata>>,
192
207
  current: Option.Option<{
193
- eventSequenceNumber: EventSequenceNumber.GlobalEventSequenceNumber
208
+ eventSequenceNumber: EventSequenceNumber.Global.Type
194
209
  metadata: Option.Option<SyncMetadata>
195
210
  }>,
196
211
  ) =>
197
212
  lastItem.pipe(
198
213
  Option.flatMap((item) => {
199
214
  const lastBatchItem = item.batch.at(-1)
200
- if (!lastBatchItem) return Option.none()
215
+ if (lastBatchItem == null) return Option.none()
201
216
  return Option.some({
202
217
  eventSequenceNumber: lastBatchItem.eventEncoded.seqNum,
203
218
  metadata: lastBatchItem.metadata,
@@ -208,11 +223,11 @@ export const makeSyncBackend =
208
223
 
209
224
  const loop = (
210
225
  cursor: Option.Option<{
211
- eventSequenceNumber: EventSequenceNumber.GlobalEventSequenceNumber
226
+ eventSequenceNumber: EventSequenceNumber.Global.Type
212
227
  metadata: Option.Option<SyncMetadata>
213
228
  }>,
214
229
  isFirst: boolean,
215
- ): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>, InvalidPullError> => {
230
+ ): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>, UnknownError> => {
216
231
  const sseStream = (live: boolean) =>
217
232
  runPullSse(cursor, live).pipe(
218
233
  Stream.emitIfEmpty({
@@ -221,7 +236,7 @@ export const makeSyncBackend =
221
236
  } as SyncBackend.PullResItem<SyncMetadata>),
222
237
  )
223
238
 
224
- const stream = isFirst ? sseStream(false) : sseStream(true)
239
+ const stream = isFirst === true ? sseStream(false) : sseStream(true)
225
240
 
226
241
  return stream.pipe(
227
242
  // Reconnect from last item if stream
@@ -235,7 +250,7 @@ export const makeSyncBackend =
235
250
  return SyncBackend.of({
236
251
  connect,
237
252
  pull: (cursor, options) => {
238
- if (options?.live) {
253
+ if (options?.live === true) {
239
254
  return ssePull(cursor)
240
255
  } else {
241
256
  return runPullSse(cursor, false).pipe(
@@ -247,15 +262,47 @@ export const makeSyncBackend =
247
262
  }
248
263
  },
249
264
  push: (batch) =>
250
- HttpClientRequest.schemaBodyJson(ApiSchema.PushPayload)(HttpClientRequest.post(pushEndpoint), {
251
- storeId,
252
- batch,
253
- }).pipe(
254
- Effect.andThen(httpClient.pipe(HttpClient.filterStatusOk).execute),
255
- Effect.andThen(HttpClientResponse.schemaBodyJson(ApiSchema.PushResponse)),
256
- Effect.mapError((cause) => InvalidPushError.make({ cause: UnexpectedError.make({ cause }) })),
257
- Effect.retry(retry?.push ?? defaultRetry),
258
- ),
265
+ Effect.gen(function* () {
266
+ const toUnknownError = (cause: unknown): UnknownError => {
267
+ if (cause instanceof UnknownError) {
268
+ return cause
269
+ }
270
+
271
+ if (cause instanceof S2LimitExceededError) {
272
+ const note =
273
+ cause.limitType === 'record-metered-bytes'
274
+ ? `S2 record exceeded ${cause.max} metered bytes (actual: ${cause.actual})`
275
+ : `S2 batch exceeded ${cause.max} (type: ${cause.limitType}, actual: ${cause.actual})`
276
+
277
+ return new UnknownError({
278
+ cause,
279
+ note,
280
+ payload: {
281
+ limitType: cause.limitType,
282
+ max: cause.max,
283
+ actual: cause.actual,
284
+ recordIndex: cause.recordIndex,
285
+ },
286
+ })
287
+ }
288
+
289
+ return new UnknownError({ cause })
290
+ }
291
+
292
+ const chunks = yield* Effect.sync(() => chunkEventsForS2(batch)).pipe(Effect.mapError(toUnknownError))
293
+
294
+ for (const chunk of chunks) {
295
+ yield* HttpClientRequest.schemaBodyJson(ApiSchema.PushPayload)(HttpClientRequest.post(pushEndpoint), {
296
+ storeId,
297
+ batch: chunk.events,
298
+ }).pipe(
299
+ Effect.andThen(httpClient.pipe(HttpClient.filterStatusOk).execute),
300
+ Effect.andThen(HttpClientResponse.schemaBodyJson(ApiSchema.PushResponse)),
301
+ Effect.mapError(toUnknownError),
302
+ Effect.retry(retry?.push ?? defaultRetry),
303
+ )
304
+ }
305
+ }),
259
306
  ping,
260
307
  isConnected,
261
308
  metadata: {