@livestore/sync-s2 0.0.0-snapshot-446f5de211c4578498f20693bd2998869be3e796

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 (48) hide show
  1. package/LICENSE +201 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/api-schema.d.ts +32 -0
  4. package/dist/api-schema.d.ts.map +1 -0
  5. package/dist/api-schema.js +17 -0
  6. package/dist/api-schema.js.map +1 -0
  7. package/dist/api-schema.test.d.ts +2 -0
  8. package/dist/api-schema.test.d.ts.map +1 -0
  9. package/dist/api-schema.test.js +20 -0
  10. package/dist/api-schema.test.js.map +1 -0
  11. package/dist/decode.d.ts +11 -0
  12. package/dist/decode.d.ts.map +1 -0
  13. package/dist/decode.js +19 -0
  14. package/dist/decode.js.map +1 -0
  15. package/dist/http-client-generated.d.ts +1477 -0
  16. package/dist/http-client-generated.d.ts.map +1 -0
  17. package/dist/http-client-generated.js +830 -0
  18. package/dist/http-client-generated.js.map +1 -0
  19. package/dist/make-s2-url.d.ts +7 -0
  20. package/dist/make-s2-url.d.ts.map +1 -0
  21. package/dist/make-s2-url.js +16 -0
  22. package/dist/make-s2-url.js.map +1 -0
  23. package/dist/mod.d.ts +8 -0
  24. package/dist/mod.d.ts.map +1 -0
  25. package/dist/mod.js +7 -0
  26. package/dist/mod.js.map +1 -0
  27. package/dist/s2-proxy-helpers.d.ts +61 -0
  28. package/dist/s2-proxy-helpers.d.ts.map +1 -0
  29. package/dist/s2-proxy-helpers.js +143 -0
  30. package/dist/s2-proxy-helpers.js.map +1 -0
  31. package/dist/sync-provider.d.ts +60 -0
  32. package/dist/sync-provider.d.ts.map +1 -0
  33. package/dist/sync-provider.js +154 -0
  34. package/dist/sync-provider.js.map +1 -0
  35. package/dist/types.d.ts +25 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +13 -0
  38. package/dist/types.js.map +1 -0
  39. package/package.json +29 -0
  40. package/src/api-schema.test.ts +21 -0
  41. package/src/api-schema.ts +24 -0
  42. package/src/decode.ts +28 -0
  43. package/src/http-client-generated.ts +1341 -0
  44. package/src/make-s2-url.ts +23 -0
  45. package/src/mod.ts +7 -0
  46. package/src/s2-proxy-helpers.ts +196 -0
  47. package/src/sync-provider.ts +267 -0
  48. package/src/types.ts +26 -0
@@ -0,0 +1,23 @@
1
+ import { shouldNeverHappen } from '@livestore/utils'
2
+ import { Schema } from '@livestore/utils/effect'
3
+ import * as ApiSchema from './api-schema.ts'
4
+
5
+ export const makeS2StreamName = (storeId: string) => storeId.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 100)
6
+
7
+ /**
8
+ * Decode `args` from URLSearchParams using Effect Schema, mirroring Electric's approach.
9
+ */
10
+ export const decodePullArgsFromSearchParams = (searchParams: URLSearchParams): typeof ApiSchema.PullArgs.Type => {
11
+ const UrlParamsSchema = Schema.Struct({ args: ApiSchema.ArgsSchema })
12
+ const argsResult = Schema.decodeUnknownEither(UrlParamsSchema)(Object.fromEntries(searchParams.entries()))
13
+
14
+ if (argsResult._tag === 'Left') {
15
+ return shouldNeverHappen(
16
+ 'Invalid search params provided to decodePullArgsFromSearchParams',
17
+ searchParams,
18
+ Object.fromEntries(searchParams.entries()),
19
+ )
20
+ }
21
+
22
+ return argsResult.right.args
23
+ }
package/src/mod.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * as ApiSchema from './api-schema.ts'
2
+ export * as HttpClientGenerated from './http-client-generated.ts'
3
+ export * from './make-s2-url.ts'
4
+ export * from './s2-proxy-helpers.ts'
5
+ export { makeSyncBackend, type SyncS2Options } from './sync-provider.ts'
6
+ export type { S2SeqNum as S2SeqNumType, SyncMetadata as SyncMetadataType } from './types.ts'
7
+ export { S2SeqNum, SyncMetadata, s2SeqNum } from './types.ts'
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Helper functions for implementing S2 API proxies.
3
+ * These utilities reduce duplication when building HTTP endpoints that bridge to S2.
4
+ */
5
+
6
+ import type { LiveStoreEvent } from '@livestore/livestore'
7
+ import type { PullArgs } from './api-schema.ts'
8
+ import { makeS2StreamName } from './make-s2-url.ts'
9
+
10
+ /** Configuration for S2 connections */
11
+ export interface S2Config {
12
+ basin: string
13
+ token: string
14
+ /** @default 'https://aws.s2.dev/v1' */
15
+ accountBase?: string
16
+ /** @default 'https://{basin}.b.aws.s2.dev/v1' */
17
+ basinBase?: string
18
+ }
19
+
20
+ // URL construction helpers
21
+ export const getBasinUrl = (config: S2Config, path: string): string => {
22
+ const base = config.basinBase ?? `https://${config.basin}.b.aws.s2.dev/v1`
23
+ return `${base}${path}`
24
+ }
25
+
26
+ export const getAccountUrl = (config: S2Config, path: string): string => {
27
+ const base = config.accountBase ?? 'https://aws.s2.dev/v1'
28
+ return `${base}${path}`
29
+ }
30
+
31
+ export const getStreamRecordsUrl = (
32
+ config: S2Config,
33
+ stream: string,
34
+ params?: { seq_num?: number; count?: number; clamp?: boolean },
35
+ ): string => {
36
+ const base = getBasinUrl(config, `/streams/${encodeURIComponent(stream)}/records`)
37
+ if (!params) return base
38
+
39
+ const searchParams = new URLSearchParams()
40
+ /** seq_num - The sequence number to start from. See: https://docs.s2.dev/api#seq_num */
41
+ if (params.seq_num !== undefined) searchParams.append('seq_num', params.seq_num.toString())
42
+ /** count - Maximum number of changes to return. See: https://docs.s2.dev/api#count */
43
+ if (params.count !== undefined) searchParams.append('count', params.count.toString())
44
+ /** clamp - Whether to clamp the response to the requested count. See: https://docs.s2.dev/api#clamp */
45
+ if (params.clamp !== undefined) searchParams.append('clamp', params.clamp.toString())
46
+
47
+ return searchParams.toString() ? `${base}?${searchParams}` : base
48
+ }
49
+
50
+ // Header helpers
51
+ export const getAuthHeaders = (token: string): Record<string, string> => ({
52
+ Authorization: `Bearer ${token}`,
53
+ })
54
+
55
+ export const getSSEHeaders = (token: string): Record<string, string> => ({
56
+ ...getAuthHeaders(token),
57
+ accept: 'text/event-stream',
58
+ 's2-format': 'raw',
59
+ })
60
+
61
+ export const getPushHeaders = (token: string): Record<string, string> => ({
62
+ ...getAuthHeaders(token),
63
+ 'content-type': 'application/json',
64
+ 's2-format': 'raw',
65
+ })
66
+
67
+ // S2 operation helpers
68
+ export const ensureBasin = async (config: S2Config): Promise<void> => {
69
+ try {
70
+ await fetch(getAccountUrl(config, '/basins'), {
71
+ method: 'POST',
72
+ headers: {
73
+ ...getAuthHeaders(config.token),
74
+ 'content-type': 'application/json',
75
+ },
76
+ body: JSON.stringify({ basin: config.basin }),
77
+ })
78
+ } catch {
79
+ // Ignore errors - basin might already exist
80
+ }
81
+ }
82
+
83
+ export const ensureStream = async (config: S2Config, stream: string): Promise<void> => {
84
+ try {
85
+ await fetch(getBasinUrl(config, '/streams'), {
86
+ method: 'POST',
87
+ headers: {
88
+ ...getAuthHeaders(config.token),
89
+ 'content-type': 'application/json',
90
+ },
91
+ body: JSON.stringify({ stream }),
92
+ })
93
+ } catch {
94
+ // Ignore errors - stream might already exist
95
+ }
96
+ }
97
+
98
+ // Request construction helpers
99
+ export const buildPullRequest = ({
100
+ config,
101
+ args,
102
+ }: {
103
+ config: S2Config
104
+ args: PullArgs
105
+ }): {
106
+ url: string
107
+ headers: Record<string, string>
108
+ } => {
109
+ const streamName = makeS2StreamName(args.storeId)
110
+ // Convert cursor (last seen record) to seq_num (where to start reading)
111
+ // cursor points to last processed record, seq_num needs to be the next record
112
+ const seq_num = args.s2SeqNum === 'from-start' ? 0 : args.s2SeqNum + 1
113
+
114
+ if (args.live) {
115
+ const url = getStreamRecordsUrl(config, streamName, { seq_num, clamp: true })
116
+ return { url, headers: getSSEHeaders(config.token) }
117
+ } 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) }
124
+ }
125
+ }
126
+
127
+ export const buildPushRequest = ({
128
+ config,
129
+ storeId,
130
+ batch,
131
+ }: {
132
+ config: S2Config
133
+ 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
+ } => {
142
+ const streamName = makeS2StreamName(storeId)
143
+ const url = getBasinUrl(config, `/streams/${encodeURIComponent(streamName)}/records`)
144
+ return {
145
+ url,
146
+ method: 'POST',
147
+ headers: getPushHeaders(config.token),
148
+ body: JSON.stringify(formatBatchForS2(batch)),
149
+ }
150
+ }
151
+
152
+ // Response helpers
153
+ export const emptyBatchResponse = (): Response => {
154
+ return new Response(JSON.stringify({ records: [] }), {
155
+ headers: { 'content-type': 'application/json' },
156
+ })
157
+ }
158
+
159
+ export const sseKeepAliveResponse = (): Response => {
160
+ return new Response('event: ping\ndata: {}\n\n', {
161
+ status: 200,
162
+ headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' },
163
+ })
164
+ }
165
+
166
+ export const successResponse = (): Response => {
167
+ const body = { success: true }
168
+ return new Response(JSON.stringify(body), {
169
+ headers: { 'content-type': 'application/json' },
170
+ })
171
+ }
172
+
173
+ export const errorResponse = (message: string, status = 500): Response => {
174
+ return new Response(JSON.stringify({ error: message }), {
175
+ status,
176
+ headers: { 'content-type': 'application/json' },
177
+ })
178
+ }
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
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * S2 Sync Provider — Client Overview
3
+ *
4
+ * Architecture
5
+ * - This package implements LiveStore's SyncBackend over a simple HTTP API that we call the "API proxy".
6
+ * The proxy exposes three verbs compatible with LiveStore's sync contract:
7
+ * - GET `/?args=...` → pull pages of events
8
+ * - POST `/` → push a batch of events
9
+ * - HEAD `/` → ping (reachability)
10
+ * - In tests, the API proxy bridges to the hosted S2 service. The proxy remains focused on app logic; all
11
+ * reusable logic (schemas, helpers, client behavior) is factored into this package.
12
+ *
13
+ * LiveStore → S2 mapping
14
+ * - storeId → S2 stream name (sanitized). Each store maps to one S2 stream.
15
+ * - LiveStore event (AnyEncodedGlobal) → S2 record body (string). We JSON-encode the event and set it as the
16
+ * S2 record body. We currently do not rely on S2 record headers.
17
+ * - Sequence numbers → INDEPENDENT systems. LiveStore's seqNum (in event payload) tracks logical event ordering;
18
+ * S2's seq_num tracks physical stream position. Both start at 0 but are decoupled by design to support future
19
+ * optimizations like compaction. SyncMetadata tracks S2's position separately for cursor management.
20
+ * - Pull (cursor) → S2 `seq_num`. The cursor uses SyncMetadata.s2SeqNum for stream positioning, not LiveStore's seqNum.
21
+ * - Live pulling → S2 SSE tail. SSE streaming provides real-time event delivery without polling.
22
+ *
23
+ * S2 constraints & considerations
24
+ * - Append limits: respect S2 batch count/byte limits; add retries/backoff for 4xx/5xx.
25
+ * - Provisioning: basin / stream lifecycle is kept outside of the client (API proxy / service concern).
26
+ * - Formatting: when speaking to S2 directly, prefer `s2-format: raw` for JSON record bodies and ensure UTF‑8.
27
+ * - Sequence numbers: S2 assigns `seq_num`; LiveStore's event sequence is preserved inside the event payload.
28
+ * DO NOT couple these systems together or assume 1:1 correspondence.
29
+ *
30
+ * Errors
31
+ * - push → InvalidPushError on non‑2xx; pull → InvalidPullError on non‑2xx; ping/connect map timeouts to offline.
32
+ * - The proxy should surface helpful status codes and error bodies.
33
+ */
34
+ import { InvalidPullError, InvalidPushError, SyncBackend, UnexpectedError } from '@livestore/common'
35
+ import type { EventSequenceNumber } from '@livestore/common/schema'
36
+ import { shouldNeverHappen } from '@livestore/utils'
37
+ import {
38
+ type Duration,
39
+ Effect,
40
+ HttpClient,
41
+ HttpClientRequest,
42
+ HttpClientResponse,
43
+ Option,
44
+ Schedule,
45
+ Schema,
46
+ Sse,
47
+ Stream,
48
+ SubscriptionRef,
49
+ } from '@livestore/utils/effect'
50
+ import * as ApiSchema from './api-schema.ts'
51
+ import { decodeReadBatch } from './decode.ts'
52
+ import * as HttpClientGenerated from './http-client-generated.ts'
53
+ import type { SyncMetadata } from './types.ts'
54
+
55
+ export interface SyncS2Options {
56
+ endpoint:
57
+ | string
58
+ | {
59
+ push: string
60
+ pull: string
61
+ ping: string
62
+ }
63
+ ping?: {
64
+ /** Enable periodic ping; default true */
65
+ enabled?: boolean
66
+ /** Timeout for individual ping request; default 10s */
67
+ requestTimeout?: Duration.DurationInput
68
+ /** Interval between ping requests; default 10s */
69
+ requestInterval?: Duration.DurationInput
70
+ }
71
+ retry?: {
72
+ /** Custom retry schedule for non-live pulls (default: 2 recurs, 100ms spaced) */
73
+ pull?: Schedule.Schedule<number, InvalidPullError>
74
+ /** Custom retry schedule for pushes (default: 2 recurs, 100ms spaced) */
75
+ push?: Schedule.Schedule<number, InvalidPushError>
76
+ }
77
+ }
78
+
79
+ export const defaultRetry = Schedule.compose(Schedule.recurs(2), Schedule.spaced(100))
80
+
81
+ export const makeSyncBackend =
82
+ ({ endpoint, ping: pingOptions, retry }: SyncS2Options): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
83
+ ({ storeId, payload }) =>
84
+ Effect.gen(function* () {
85
+ const isConnected = yield* SubscriptionRef.make(false)
86
+ const pullEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.pull
87
+ const pushEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.push
88
+ const pingEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.ping
89
+
90
+ const httpClient = yield* HttpClient.HttpClient
91
+
92
+ const pullEndpointHasSameOrigin =
93
+ pullEndpoint.startsWith('/') ||
94
+ (globalThis.location !== undefined && globalThis.location.origin === new URL(pullEndpoint).origin)
95
+
96
+ const pingTimeout = pingOptions?.requestTimeout ?? 10_000
97
+
98
+ const ping: SyncBackend.SyncBackend<SyncMetadata>['ping'] = Effect.gen(function* () {
99
+ yield* httpClient.pipe(HttpClient.filterStatusOk).head(pingEndpoint)
100
+ yield* SubscriptionRef.set(isConnected, true)
101
+ }).pipe(
102
+ UnexpectedError.mapToUnexpectedError,
103
+ Effect.timeout(pingTimeout),
104
+ Effect.catchTag('TimeoutException', () => SubscriptionRef.set(isConnected, false)),
105
+ )
106
+
107
+ const pingInterval = pingOptions?.requestInterval ?? 10_000
108
+ if (pingOptions?.enabled !== false) {
109
+ yield* ping.pipe(Effect.repeat(Schedule.spaced(pingInterval)), Effect.tapCauseLogPretty, Effect.forkScoped)
110
+ }
111
+
112
+ // 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)
116
+
117
+ const runPullSse = (
118
+ cursor: Option.Option<{
119
+ eventSequenceNumber: EventSequenceNumber.GlobalEventSequenceNumber
120
+ metadata: Option.Option<SyncMetadata>
121
+ }>,
122
+ live: boolean,
123
+ ): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>, InvalidPullError> => {
124
+ // Extract S2 seqNum from metadata for SSE cursor
125
+ const s2SeqNum = cursor.pipe(
126
+ Option.flatMap((_) => _.metadata),
127
+ Option.map((_) => _.s2SeqNum),
128
+ Option.getOrElse(() => 'from-start' as const),
129
+ )
130
+
131
+ const argsJson = Schema.encodeSync(ApiSchema.ArgsSchema)({ storeId, payload, s2SeqNum, live })
132
+ const url = `${pullEndpoint}?args=${argsJson}`
133
+
134
+ return httpClient
135
+ .execute(HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders({ accept: 'text/event-stream' })))
136
+ .pipe(
137
+ HttpClientResponse.stream,
138
+ // decode text and split into lines
139
+ Stream.decodeText('utf8'),
140
+ Stream.pipeThroughChannel(Sse.makeChannel()),
141
+ // Filter out pings, map errors to stream failures
142
+ Stream.mapEffect(
143
+ Effect.fnUntraced(function* (msg) {
144
+ const evt = msg.event.toLowerCase()
145
+ if (evt === 'ping') return Option.none()
146
+ if (evt === 'error') {
147
+ return yield* new InvalidPullError({ cause: new Error(`SSE error: ${msg.data}`) })
148
+ }
149
+ if (evt === 'batch') {
150
+ const readBatch = yield* Schema.decode(Schema.parseJson(HttpClientGenerated.ReadBatch))(msg.data)
151
+ const batch = decodeReadBatch(readBatch)
152
+
153
+ const lastSeqNum = batch.at(-1)?.eventEncoded.seqNum
154
+ const tailSeqNum = readBatch.tail?.seq_num
155
+ const remaining =
156
+ lastSeqNum !== undefined && tailSeqNum !== undefined ? tailSeqNum - lastSeqNum : undefined
157
+
158
+ return Option.some({
159
+ batch,
160
+ pageInfo:
161
+ remaining !== undefined && remaining > 0
162
+ ? SyncBackend.pageInfoMoreKnown(remaining)
163
+ : SyncBackend.pageInfoNoMore,
164
+ })
165
+ }
166
+ // emitted when reached the end of the stream
167
+ if (evt === 'message' && msg.data === '[DONE]') {
168
+ return Option.none()
169
+ }
170
+ return shouldNeverHappen(`Unexpected SSE event: ${evt}`, msg)
171
+ }),
172
+ ),
173
+ Stream.filterMap((_) => _), // filter out Option.none()
174
+ Stream.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : new InvalidPullError({ cause }))),
175
+ Stream.retry(retry?.pull ?? defaultRetry),
176
+ )
177
+ }
178
+
179
+ const ssePull = (
180
+ startCursor: Option.Option<{
181
+ eventSequenceNumber: EventSequenceNumber.GlobalEventSequenceNumber
182
+ metadata: Option.Option<SyncMetadata>
183
+ }>,
184
+ ): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>, InvalidPullError> => {
185
+ const computeNextCursor = (
186
+ lastItem: Option.Option<SyncBackend.PullResItem<SyncMetadata>>,
187
+ current: Option.Option<{
188
+ eventSequenceNumber: EventSequenceNumber.GlobalEventSequenceNumber
189
+ metadata: Option.Option<SyncMetadata>
190
+ }>,
191
+ ) =>
192
+ lastItem.pipe(
193
+ Option.flatMap((item) => {
194
+ const lastBatchItem = item.batch.at(-1)
195
+ if (!lastBatchItem) return Option.none()
196
+ return Option.some({
197
+ eventSequenceNumber: lastBatchItem.eventEncoded.seqNum,
198
+ metadata: lastBatchItem.metadata,
199
+ })
200
+ }),
201
+ Option.orElse(() => current),
202
+ )
203
+
204
+ const loop = (
205
+ cursor: Option.Option<{
206
+ eventSequenceNumber: EventSequenceNumber.GlobalEventSequenceNumber
207
+ metadata: Option.Option<SyncMetadata>
208
+ }>,
209
+ isFirst: boolean,
210
+ ): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>, InvalidPullError> => {
211
+ const sseStream = (live: boolean) =>
212
+ runPullSse(cursor, live).pipe(
213
+ Stream.emitIfEmpty({
214
+ batch: [],
215
+ pageInfo: SyncBackend.pageInfoNoMore,
216
+ } as SyncBackend.PullResItem<SyncMetadata>),
217
+ )
218
+
219
+ const stream = isFirst ? sseStream(false) : sseStream(true)
220
+
221
+ return stream.pipe(
222
+ // Reconnect from last item if stream
223
+ Stream.concatWithLastElement((lastItem) => loop(computeNextCursor(lastItem, cursor), false)),
224
+ )
225
+ }
226
+
227
+ return loop(startCursor, true)
228
+ }
229
+
230
+ return SyncBackend.of({
231
+ connect,
232
+ pull: (cursor, options) => {
233
+ if (options?.live) {
234
+ return ssePull(cursor)
235
+ } else {
236
+ return runPullSse(cursor, false).pipe(
237
+ Stream.emitIfEmpty({
238
+ batch: [],
239
+ pageInfo: SyncBackend.pageInfoNoMore,
240
+ } as SyncBackend.PullResItem<SyncMetadata>),
241
+ )
242
+ }
243
+ },
244
+ push: (batch) =>
245
+ HttpClientRequest.schemaBodyJson(ApiSchema.PushPayload)(HttpClientRequest.post(pushEndpoint), {
246
+ storeId,
247
+ batch,
248
+ }).pipe(
249
+ Effect.andThen(httpClient.pipe(HttpClient.filterStatusOk).execute),
250
+ Effect.andThen(HttpClientResponse.schemaBodyJson(ApiSchema.PushResponse)),
251
+ Effect.mapError((cause) => InvalidPushError.make({ cause: UnexpectedError.make({ cause }) })),
252
+ Effect.retry(retry?.push ?? defaultRetry),
253
+ ),
254
+ ping,
255
+ isConnected,
256
+ metadata: {
257
+ name: '@livestore/sync-s2',
258
+ description: 'LiveStore sync backend implementation for S2',
259
+ protocol: 'http',
260
+ endpoint,
261
+ },
262
+ supports: {
263
+ pullPageInfoKnown: false,
264
+ pullLive: true,
265
+ },
266
+ })
267
+ })
package/src/types.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { Brand, Schema } from '@livestore/utils/effect'
2
+
3
+ /**
4
+ * Branded type for S2's sequence numbers to ensure type safety
5
+ * and prevent accidental mixing with LiveStore's sequence numbers.
6
+ *
7
+ * S2 sequence numbers:
8
+ * - Start at 0 for the first record in a stream
9
+ * - Are assigned sequentially by S2 for each appended record
10
+ * - Are used for physical stream positioning (reading from a specific point)
11
+ * - Are completely independent from LiveStore's logical event sequence numbers
12
+ */
13
+ export type S2SeqNum = Brand.Branded<number, 'S2SeqNum'>
14
+ export const s2SeqNum = Brand.nominal<S2SeqNum>()
15
+ export const S2SeqNum = Schema.fromBrand(s2SeqNum)(Schema.Int.pipe(Schema.greaterThanOrEqualTo(0)))
16
+
17
+ /**
18
+ * Metadata for tracking S2-specific cursor information.
19
+ * This is separate from LiveStore's event sequence numbers to maintain
20
+ * proper abstraction boundaries between storage and application logic.
21
+ */
22
+ export const SyncMetadata = Schema.Struct({
23
+ /** S2's seq_num for stream positioning */
24
+ s2SeqNum: S2SeqNum,
25
+ })
26
+ export type SyncMetadata = typeof SyncMetadata.Type