@livestore/sync-s2 0.4.0-dev.9 → 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 (54) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/api-schema.d.ts +19 -19
  3. package/dist/api-schema.d.ts.map +1 -1
  4. package/dist/api-schema.js +4 -4
  5. package/dist/api-schema.js.map +1 -1
  6. package/dist/api-schema.test.js +15 -10
  7. package/dist/api-schema.test.js.map +1 -1
  8. package/dist/decode.d.ts +1 -1
  9. package/dist/decode.d.ts.map +1 -1
  10. package/dist/decode.js +3 -3
  11. package/dist/decode.js.map +1 -1
  12. package/dist/http-client-generated.d.ts +337 -1051
  13. package/dist/http-client-generated.d.ts.map +1 -1
  14. package/dist/http-client-generated.js +276 -231
  15. package/dist/http-client-generated.js.map +1 -1
  16. package/dist/limits.d.ts +39 -0
  17. package/dist/limits.d.ts.map +1 -0
  18. package/dist/limits.js +89 -0
  19. package/dist/limits.js.map +1 -0
  20. package/dist/limits.test.d.ts +2 -0
  21. package/dist/limits.test.d.ts.map +1 -0
  22. package/dist/limits.test.js +30 -0
  23. package/dist/limits.test.js.map +1 -0
  24. package/dist/make-s2-url.d.ts.map +1 -1
  25. package/dist/make-s2-url.js +4 -4
  26. package/dist/make-s2-url.js.map +1 -1
  27. package/dist/mod.d.ts +1 -0
  28. package/dist/mod.d.ts.map +1 -1
  29. package/dist/mod.js +1 -0
  30. package/dist/mod.js.map +1 -1
  31. package/dist/s2-proxy-helpers.d.ts +26 -22
  32. package/dist/s2-proxy-helpers.d.ts.map +1 -1
  33. package/dist/s2-proxy-helpers.js +35 -33
  34. package/dist/s2-proxy-helpers.js.map +1 -1
  35. package/dist/sync-provider.d.ts +7 -7
  36. package/dist/sync-provider.d.ts.map +1 -1
  37. package/dist/sync-provider.js +55 -22
  38. package/dist/sync-provider.js.map +1 -1
  39. package/dist/types.d.ts +3 -3
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/types.js +1 -1
  42. package/dist/types.js.map +1 -1
  43. package/package.json +57 -13
  44. package/src/api-schema.test.ts +16 -10
  45. package/src/api-schema.ts +5 -4
  46. package/src/decode.ts +5 -4
  47. package/src/http-client-generated.ts +187 -258
  48. package/src/limits.test.ts +43 -0
  49. package/src/limits.ts +133 -0
  50. package/src/make-s2-url.ts +5 -4
  51. package/src/mod.ts +1 -0
  52. package/src/s2-proxy-helpers.ts +57 -47
  53. package/src/sync-provider.ts +88 -40
  54. package/src/types.ts +1 -1
@@ -0,0 +1,43 @@
1
+ import type { EventSequenceNumber, LiveStoreEvent } from '@livestore/common/schema'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import {
5
+ chunkEventsForS2,
6
+ computeRecordMeteredBytes,
7
+ MAX_BATCH_METERED_BYTES,
8
+ MAX_RECORD_METERED_BYTES,
9
+ S2LimitExceededError,
10
+ } from './limits.ts'
11
+
12
+ const encoder = new TextEncoder()
13
+
14
+ const makeEvent = (payloadLength: number, index = 0): LiveStoreEvent.Global.Encoded => ({
15
+ name: `event-${index}`,
16
+ args: { payload: 'x'.repeat(payloadLength) },
17
+ seqNum: index as EventSequenceNumber.Global.Type,
18
+ parentSeqNum: index as EventSequenceNumber.Global.Type,
19
+ clientId: 'client',
20
+ sessionId: 'session',
21
+ })
22
+
23
+ describe('S2 limits helpers', () => {
24
+ it('computes metered bytes for record bodies', () => {
25
+ const record = { body: JSON.stringify({ hello: 'world' }) }
26
+ const expected = 8 + encoder.encode(record.body ?? '').byteLength
27
+ expect(computeRecordMeteredBytes(record)).toBe(expected)
28
+ })
29
+
30
+ it('splits large batches while respecting metered byte limits', () => {
31
+ const events = [makeEvent(400_000, 1), makeEvent(400_000, 2), makeEvent(400_000, 3)]
32
+ const chunks = chunkEventsForS2(events)
33
+
34
+ expect(chunks).toHaveLength(2)
35
+ expect(chunks.map((chunk) => chunk.events.length)).toStrictEqual([2, 1])
36
+ expect(chunks.every((chunk) => chunk.meteredBytes <= MAX_BATCH_METERED_BYTES)).toBe(true)
37
+ })
38
+
39
+ it('throws when a single record exceeds the metered byte cap', () => {
40
+ const oversize = makeEvent(MAX_RECORD_METERED_BYTES, 1)
41
+ expect(() => chunkEventsForS2([oversize])).toThrow(S2LimitExceededError)
42
+ })
43
+ })
package/src/limits.ts ADDED
@@ -0,0 +1,133 @@
1
+ import type { LiveStoreEvent } from '@livestore/common/schema'
2
+ import { splitArrayBySize } from '@livestore/common/sync'
3
+ import { Effect, ReadonlyArray as EffectArray, Schema } from '@livestore/utils/effect'
4
+
5
+ const textEncoder = new TextEncoder()
6
+
7
+ /**
8
+ * Maximum metered size of a single record (docs: https://s2.dev/docs/limits#records).
9
+ */
10
+ export const MAX_RECORD_METERED_BYTES = 1_048_576 // 1 MiB
11
+
12
+ /**
13
+ * Maximum combined metered size of a batch append (docs: https://s2.dev/docs/limits#records).
14
+ */
15
+ export const MAX_BATCH_METERED_BYTES = 1_048_576 // 1 MiB
16
+
17
+ /**
18
+ * Maximum number of records per append (docs: https://s2.dev/docs/limits#records).
19
+ */
20
+ export const MAX_RECORDS_PER_BATCH = 1_000
21
+
22
+ const LimitType = Schema.Literals(['record-metered-bytes', 'batch-metered-bytes', 'batch-count'])
23
+
24
+ export class S2LimitExceededError extends Schema.TaggedError<S2LimitExceededError>(
25
+ '~@livestore/sync-s2/S2LimitExceededError',
26
+ )('S2LimitExceededError', {
27
+ limitType: LimitType,
28
+ max: Schema.Number,
29
+ actual: Schema.Number,
30
+ recordIndex: Schema.optional(Schema.Number),
31
+ }) {}
32
+
33
+ export interface AppendRecordBody {
34
+ readonly body?: string
35
+ readonly headers?: ReadonlyArray<{ readonly name: string; readonly value: string }>
36
+ }
37
+
38
+ // S2 measures bodies/headers in UTF‑8 bytes; centralising this helper keeps the
39
+ // formula readable and consistent with the docs.
40
+ const utf8ByteLength = (value: string): number => textEncoder.encode(value).byteLength
41
+
42
+ export const computeRecordMeteredBytes = (record: AppendRecordBody): number => {
43
+ const headers = record.headers ?? []
44
+ const headerCount = headers.length
45
+ const headerBytes = headers.reduce(
46
+ (acc, header) => acc + utf8ByteLength(header.name) + utf8ByteLength(header.value),
47
+ 0,
48
+ )
49
+ const bodyBytes = record.body === undefined ? 0 : utf8ByteLength(record.body)
50
+ return 8 + 2 * headerCount + headerBytes + bodyBytes
51
+ }
52
+
53
+ export const computeBatchMeteredBytes = (records: ReadonlyArray<AppendRecordBody>): number =>
54
+ records.reduce((acc, record) => acc + computeRecordMeteredBytes(record), 0)
55
+
56
+ interface PreparedEvent {
57
+ readonly event: LiveStoreEvent.Global.Encoded
58
+ readonly record: AppendRecordBody
59
+ readonly meteredBytes: number
60
+ readonly index: number
61
+ }
62
+
63
+ export interface S2Chunk {
64
+ readonly events: ReadonlyArray<LiveStoreEvent.Global.Encoded>
65
+ readonly records: ReadonlyArray<AppendRecordBody>
66
+ readonly meteredBytes: number
67
+ }
68
+
69
+ // Pre-stringify events and pre-compute per-record metered bytes so we only pay
70
+ // the JSON cost once when chunking large batches.
71
+ const convertEventsToPrepared = (events: ReadonlyArray<LiveStoreEvent.Global.Encoded>): PreparedEvent[] =>
72
+ events.map((event, index) => {
73
+ const body = JSON.stringify(event)
74
+ const record: AppendRecordBody = { body }
75
+ const meteredBytes = computeRecordMeteredBytes(record)
76
+
77
+ if (meteredBytes > MAX_RECORD_METERED_BYTES) {
78
+ throw new S2LimitExceededError({
79
+ limitType: 'record-metered-bytes',
80
+ max: MAX_RECORD_METERED_BYTES,
81
+ actual: meteredBytes,
82
+ recordIndex: index,
83
+ })
84
+ }
85
+
86
+ return { event, record, meteredBytes, index }
87
+ })
88
+
89
+ // Summarises a chunk’s metered bytes. Passed to splitChunkBySize so we enforce
90
+ // S2 limits directly instead of relying on JSON size heuristics.
91
+ const makeChunkMeasure = (items: ReadonlyArray<PreparedEvent>): number =>
92
+ items.reduce((acc, item) => acc + item.meteredBytes, 0)
93
+
94
+ const mapPreparedChunks = (chunks: ReadonlyArray<ReadonlyArray<PreparedEvent>>): ReadonlyArray<S2Chunk> =>
95
+ chunks.map((chunkItems) => {
96
+ const events = chunkItems.map((item) => item.event)
97
+ const records = chunkItems.map((item) => item.record)
98
+ return {
99
+ events,
100
+ records,
101
+ meteredBytes: makeChunkMeasure(chunkItems),
102
+ }
103
+ })
104
+
105
+ export const chunkEventsForS2 = (events: ReadonlyArray<LiveStoreEvent.Global.Encoded>): ReadonlyArray<S2Chunk> => {
106
+ const prepared = convertEventsToPrepared(events)
107
+
108
+ if (EffectArray.isReadonlyArrayNonEmpty(prepared) === false) {
109
+ return []
110
+ }
111
+
112
+ try {
113
+ const chunks = splitArrayBySize<PreparedEvent>({
114
+ maxItems: MAX_RECORDS_PER_BATCH,
115
+ maxBytes: MAX_BATCH_METERED_BYTES,
116
+ encode: (items) => ({ records: items.map((item) => item.record) }),
117
+ measure: makeChunkMeasure,
118
+ })(prepared).pipe(Effect.runSync)
119
+
120
+ return mapPreparedChunks(chunks)
121
+ } catch (error) {
122
+ if (error !== undefined && typeof error === 'object' && (error as any)._tag === 'OversizeChunkItemError') {
123
+ const oversize = error as { size: number; maxBytes: number; _tag: string }
124
+ throw new S2LimitExceededError({
125
+ limitType: 'record-metered-bytes',
126
+ max: oversize.maxBytes,
127
+ actual: oversize.size,
128
+ })
129
+ }
130
+
131
+ throw error
132
+ }
133
+ }
@@ -1,5 +1,6 @@
1
1
  import { shouldNeverHappen } from '@livestore/utils'
2
- import { Schema } from '@livestore/utils/effect'
2
+ import { Result, Schema } from '@livestore/utils/effect'
3
+
3
4
  import * as ApiSchema from './api-schema.ts'
4
5
 
5
6
  export const makeS2StreamName = (storeId: string) => storeId.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 100)
@@ -9,9 +10,9 @@ export const makeS2StreamName = (storeId: string) => storeId.replace(/[^a-zA-Z0-
9
10
  */
10
11
  export const decodePullArgsFromSearchParams = (searchParams: URLSearchParams): typeof ApiSchema.PullArgs.Type => {
11
12
  const UrlParamsSchema = Schema.Struct({ args: ApiSchema.ArgsSchema })
12
- const argsResult = Schema.decodeUnknownEither(UrlParamsSchema)(Object.fromEntries(searchParams.entries()))
13
+ const argsResult = Schema.decodeUnknownResult(UrlParamsSchema)(Object.fromEntries(searchParams.entries()))
13
14
 
14
- if (argsResult._tag === 'Left') {
15
+ if (Result.isFailure(argsResult) === true) {
15
16
  return shouldNeverHappen(
16
17
  'Invalid search params provided to decodePullArgsFromSearchParams',
17
18
  searchParams,
@@ -19,5 +20,5 @@ export const decodePullArgsFromSearchParams = (searchParams: URLSearchParams): t
19
20
  )
20
21
  }
21
22
 
22
- return argsResult.right.args
23
+ return argsResult.success.args
23
24
  }
package/src/mod.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * as ApiSchema from './api-schema.ts'
2
2
  export * as HttpClientGenerated from './http-client-generated.ts'
3
+ export * from './limits.ts'
3
4
  export * from './make-s2-url.ts'
4
5
  export * from './s2-proxy-helpers.ts'
5
6
  export { makeSyncBackend, type SyncS2Options } from './sync-provider.ts'
@@ -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,28 +28,31 @@
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 {
38
38
  type Duration,
39
39
  Effect,
40
+ Filter,
40
41
  HttpClient,
41
42
  HttpClientRequest,
42
43
  HttpClientResponse,
43
44
  Option,
44
45
  Schedule,
45
46
  Schema,
46
- Sse,
47
47
  Stream,
48
48
  SubscriptionRef,
49
49
  } from '@livestore/utils/effect'
50
+ import { Sse } from 'effect/unstable/encoding'
51
+
50
52
  import * as ApiSchema from './api-schema.ts'
51
53
  import { decodeReadBatch } from './decode.ts'
52
54
  import * as HttpClientGenerated from './http-client-generated.ts'
55
+ import { chunkEventsForS2, S2LimitExceededError } from './limits.ts'
53
56
  import type { SyncMetadata } from './types.ts'
54
57
 
55
58
  export interface SyncS2Options {
@@ -64,19 +67,31 @@ export interface SyncS2Options {
64
67
  /** Enable periodic ping; default true */
65
68
  enabled?: boolean
66
69
  /** Timeout for individual ping request; default 10s */
67
- requestTimeout?: Duration.DurationInput
70
+ requestTimeout?: Duration.Input
68
71
  /** Interval between ping requests; default 10s */
69
- requestInterval?: Duration.DurationInput
72
+ requestInterval?: Duration.Input
70
73
  }
71
74
  retry?: {
72
75
  /** Custom retry schedule for non-live pulls (default: 2 recurs, 100ms spaced) */
73
- pull?: Schedule.Schedule<number, InvalidPullError>
76
+ pull?: Schedule.Schedule<number, UnknownError>
74
77
  /** Custom retry schedule for pushes (default: 2 recurs, 100ms spaced) */
75
- push?: Schedule.Schedule<number, InvalidPushError>
78
+ push?: Schedule.Schedule<number, UnknownError>
76
79
  }
77
80
  }
78
81
 
79
- export const defaultRetry = Schedule.compose(Schedule.recurs(2), Schedule.spaced(100))
82
+ export const defaultRetry = Schedule.recurs(2).pipe(Schedule.addDelay(() => Effect.succeed(100)))
83
+
84
+ const getBrowserOrigin = () => {
85
+ if (typeof globalThis !== 'object' || globalThis === null || !('location' in globalThis)) {
86
+ return undefined
87
+ }
88
+
89
+ const { location } = globalThis as typeof globalThis & {
90
+ location?: { origin?: unknown } | undefined
91
+ }
92
+
93
+ return typeof location?.origin === 'string' ? location.origin : undefined
94
+ }
80
95
 
81
96
  export const makeSyncBackend =
82
97
  ({ endpoint, ping: pingOptions, retry }: SyncS2Options): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
@@ -89,9 +104,9 @@ export const makeSyncBackend =
89
104
 
90
105
  const httpClient = yield* HttpClient.HttpClient
91
106
 
107
+ const browserOrigin = getBrowserOrigin()
92
108
  const pullEndpointHasSameOrigin =
93
- pullEndpoint.startsWith('/') ||
94
- (globalThis.location !== undefined && globalThis.location.origin === new URL(pullEndpoint).origin)
109
+ pullEndpoint.startsWith('/') || (browserOrigin !== undefined && browserOrigin === new URL(pullEndpoint).origin)
95
110
 
96
111
  const pingTimeout = pingOptions?.requestTimeout ?? 10_000
97
112
 
@@ -99,9 +114,9 @@ export const makeSyncBackend =
99
114
  yield* httpClient.pipe(HttpClient.filterStatusOk).head(pingEndpoint)
100
115
  yield* SubscriptionRef.set(isConnected, true)
101
116
  }).pipe(
102
- UnexpectedError.mapToUnexpectedError,
117
+ UnknownError.mapToUnknownError,
103
118
  Effect.timeout(pingTimeout),
104
- Effect.catchTag('TimeoutException', () => SubscriptionRef.set(isConnected, false)),
119
+ Effect.catchTag('TimeoutError', () => SubscriptionRef.set(isConnected, false)),
105
120
  )
106
121
 
107
122
  const pingInterval = pingOptions?.requestInterval ?? 10_000
@@ -110,17 +125,16 @@ export const makeSyncBackend =
110
125
  }
111
126
 
112
127
  // 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)
128
+ const connect: SyncBackend.SyncBackend<SyncMetadata>['connect'] =
129
+ pullEndpointHasSameOrigin === true ? Effect.void : ping.pipe(UnknownError.mapToUnknownError)
116
130
 
117
131
  const runPullSse = (
118
132
  cursor: Option.Option<{
119
- eventSequenceNumber: EventSequenceNumber.GlobalEventSequenceNumber
133
+ eventSequenceNumber: EventSequenceNumber.Global.Type
120
134
  metadata: Option.Option<SyncMetadata>
121
135
  }>,
122
136
  live: boolean,
123
- ): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>, InvalidPullError> => {
137
+ ): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>, UnknownError> => {
124
138
  // Extract S2 seqNum from metadata for SSE cursor
125
139
  const s2SeqNum = cursor.pipe(
126
140
  Option.flatMap((_) => _.metadata),
@@ -136,18 +150,20 @@ export const makeSyncBackend =
136
150
  .pipe(
137
151
  HttpClientResponse.stream,
138
152
  // decode text and split into lines
139
- Stream.decodeText('utf8'),
140
- Stream.pipeThroughChannel(Sse.makeChannel()),
153
+ Stream.decodeText({ encoding: 'utf8' }),
154
+ Stream.pipeThroughChannel(Sse.decode()),
141
155
  // Filter out pings, map errors to stream failures
142
156
  Stream.mapEffect(
143
157
  Effect.fnUntraced(function* (msg) {
144
158
  const evt = msg.event.toLowerCase()
145
159
  if (evt === 'ping') return Option.none()
146
160
  if (evt === 'error') {
147
- return yield* new InvalidPullError({ cause: new Error(`SSE error: ${msg.data}`) })
161
+ return yield* new UnknownError({ cause: new Error(`SSE error: ${msg.data}`) })
148
162
  }
149
163
  if (evt === 'batch') {
150
- const readBatch = yield* Schema.decode(Schema.parseJson(HttpClientGenerated.ReadBatch))(msg.data)
164
+ const readBatch = yield* Schema.decodeEffect(Schema.fromJsonString(HttpClientGenerated.ReadBatch))(
165
+ msg.data,
166
+ )
151
167
  const batch = decodeReadBatch(readBatch)
152
168
 
153
169
  const lastS2SeqNum = batch.at(-1)?.metadata.pipe(
@@ -175,29 +191,29 @@ export const makeSyncBackend =
175
191
  return shouldNeverHappen(`Unexpected SSE event: ${evt}`, msg)
176
192
  }),
177
193
  ),
178
- Stream.filterMap((_) => _), // filter out Option.none()
179
- Stream.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : new InvalidPullError({ cause }))),
194
+ Stream.filterMap(Filter.fromPredicateOption((_) => _)), // filter out Option.none()
195
+ Stream.mapError((cause) => (cause._tag === 'UnknownError' ? cause : new UnknownError({ cause }))),
180
196
  Stream.retry(retry?.pull ?? defaultRetry),
181
197
  )
182
198
  }
183
199
 
184
200
  const ssePull = (
185
201
  startCursor: Option.Option<{
186
- eventSequenceNumber: EventSequenceNumber.GlobalEventSequenceNumber
202
+ eventSequenceNumber: EventSequenceNumber.Global.Type
187
203
  metadata: Option.Option<SyncMetadata>
188
204
  }>,
189
- ): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>, InvalidPullError> => {
205
+ ): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>, UnknownError> => {
190
206
  const computeNextCursor = (
191
207
  lastItem: Option.Option<SyncBackend.PullResItem<SyncMetadata>>,
192
208
  current: Option.Option<{
193
- eventSequenceNumber: EventSequenceNumber.GlobalEventSequenceNumber
209
+ eventSequenceNumber: EventSequenceNumber.Global.Type
194
210
  metadata: Option.Option<SyncMetadata>
195
211
  }>,
196
212
  ) =>
197
213
  lastItem.pipe(
198
214
  Option.flatMap((item) => {
199
215
  const lastBatchItem = item.batch.at(-1)
200
- if (!lastBatchItem) return Option.none()
216
+ if (lastBatchItem == null) return Option.none()
201
217
  return Option.some({
202
218
  eventSequenceNumber: lastBatchItem.eventEncoded.seqNum,
203
219
  metadata: lastBatchItem.metadata,
@@ -208,11 +224,11 @@ export const makeSyncBackend =
208
224
 
209
225
  const loop = (
210
226
  cursor: Option.Option<{
211
- eventSequenceNumber: EventSequenceNumber.GlobalEventSequenceNumber
227
+ eventSequenceNumber: EventSequenceNumber.Global.Type
212
228
  metadata: Option.Option<SyncMetadata>
213
229
  }>,
214
230
  isFirst: boolean,
215
- ): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>, InvalidPullError> => {
231
+ ): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>, UnknownError> => {
216
232
  const sseStream = (live: boolean) =>
217
233
  runPullSse(cursor, live).pipe(
218
234
  Stream.emitIfEmpty({
@@ -221,7 +237,7 @@ export const makeSyncBackend =
221
237
  } as SyncBackend.PullResItem<SyncMetadata>),
222
238
  )
223
239
 
224
- const stream = isFirst ? sseStream(false) : sseStream(true)
240
+ const stream = isFirst === true ? sseStream(false) : sseStream(true)
225
241
 
226
242
  return stream.pipe(
227
243
  // Reconnect from last item if stream
@@ -235,7 +251,7 @@ export const makeSyncBackend =
235
251
  return SyncBackend.of({
236
252
  connect,
237
253
  pull: (cursor, options) => {
238
- if (options?.live) {
254
+ if (options?.live === true) {
239
255
  return ssePull(cursor)
240
256
  } else {
241
257
  return runPullSse(cursor, false).pipe(
@@ -247,15 +263,47 @@ export const makeSyncBackend =
247
263
  }
248
264
  },
249
265
  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
- ),
266
+ Effect.gen(function* () {
267
+ const toUnknownError = (cause: unknown): UnknownError => {
268
+ if (cause instanceof UnknownError) {
269
+ return cause
270
+ }
271
+
272
+ if (cause instanceof S2LimitExceededError) {
273
+ const note =
274
+ cause.limitType === 'record-metered-bytes'
275
+ ? `S2 record exceeded ${cause.max} metered bytes (actual: ${cause.actual})`
276
+ : `S2 batch exceeded ${cause.max} (type: ${cause.limitType}, actual: ${cause.actual})`
277
+
278
+ return new UnknownError({
279
+ cause,
280
+ note,
281
+ payload: {
282
+ limitType: cause.limitType,
283
+ max: cause.max,
284
+ actual: cause.actual,
285
+ recordIndex: cause.recordIndex,
286
+ },
287
+ })
288
+ }
289
+
290
+ return new UnknownError({ cause })
291
+ }
292
+
293
+ const chunks = yield* Effect.sync(() => chunkEventsForS2(batch)).pipe(Effect.mapError(toUnknownError))
294
+
295
+ for (const chunk of chunks) {
296
+ yield* HttpClientRequest.schemaBodyJson(ApiSchema.PushPayload)(HttpClientRequest.post(pushEndpoint), {
297
+ storeId,
298
+ batch: chunk.events,
299
+ }).pipe(
300
+ Effect.andThen(httpClient.pipe(HttpClient.filterStatusOk).execute),
301
+ Effect.andThen(HttpClientResponse.schemaBodyJson(ApiSchema.PushResponse)),
302
+ Effect.mapError(toUnknownError),
303
+ Effect.retry(retry?.push ?? defaultRetry),
304
+ )
305
+ }
306
+ }),
259
307
  ping,
260
308
  isConnected,
261
309
  metadata: {
package/src/types.ts CHANGED
@@ -12,7 +12,7 @@ import { Brand, Schema } from '@livestore/utils/effect'
12
12
  */
13
13
  export type S2SeqNum = Brand.Branded<number, 'S2SeqNum'>
14
14
  export const s2SeqNum = Brand.nominal<S2SeqNum>()
15
- export const S2SeqNum = Schema.fromBrand(s2SeqNum)(Schema.Int.pipe(Schema.greaterThanOrEqualTo(0)))
15
+ export const S2SeqNum = Schema.fromBrand('S2SeqNum', s2SeqNum)(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)))
16
16
 
17
17
  /**
18
18
  * Metadata for tracking S2-specific cursor information.