@livestore/sync-electric 0.4.0-dev.2 → 0.4.0-dev.21

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.
@@ -0,0 +1,79 @@
1
+ import { shouldNeverHappen } from '@livestore/utils';
2
+ import { Hash, Schema } from '@livestore/utils/effect';
3
+ import * as ApiSchema from "./api-schema.js";
4
+ /**
5
+ * This function should be called in a trusted environment (e.g. a proxy server) as it
6
+ * requires access to senstive information (e.g. `apiSecret` / `sourceSecret`).
7
+ */
8
+ export const makeElectricUrl = ({ electricHost, searchParams: providedSearchParams, sourceId, sourceSecret, apiSecret, }) => {
9
+ const endpointUrl = `${electricHost}/v1/shape`;
10
+ const UrlParamsSchema = Schema.Struct({ args: ApiSchema.ArgsSchema });
11
+ const argsResult = Schema.decodeUnknownEither(UrlParamsSchema)(Object.fromEntries(providedSearchParams.entries()));
12
+ if (argsResult._tag === 'Left') {
13
+ return shouldNeverHappen('Invalid search params provided to makeElectricUrl', providedSearchParams, Object.fromEntries(providedSearchParams.entries()));
14
+ }
15
+ const args = argsResult.right.args;
16
+ const tableName = toTableName(args.storeId);
17
+ // TODO refactor with Effect URLSearchParams schema
18
+ // https://electric-sql.com/openapi.html
19
+ const searchParams = new URLSearchParams();
20
+ // Electric requires table names with capital letters to be quoted
21
+ // Since our table names include the storeId which may have capitals, we always quote
22
+ searchParams.set('table', `"${tableName}"`);
23
+ if (sourceId !== undefined) {
24
+ searchParams.set('source_id', sourceId);
25
+ }
26
+ if (sourceSecret !== undefined) {
27
+ searchParams.set('source_secret', sourceSecret);
28
+ }
29
+ if (apiSecret !== undefined) {
30
+ searchParams.set('api_secret', apiSecret);
31
+ }
32
+ if (args.handle._tag === 'None') {
33
+ searchParams.set('offset', '-1');
34
+ }
35
+ else {
36
+ searchParams.set('offset', args.handle.value.offset);
37
+ searchParams.set('handle', args.handle.value.handle);
38
+ searchParams.set('live', args.live ? 'true' : 'false');
39
+ }
40
+ const payload = args.payload;
41
+ const url = `${endpointUrl}?${searchParams.toString()}`;
42
+ return { url, storeId: args.storeId, needsInit: args.handle._tag === 'None', payload };
43
+ };
44
+ export const toTableName = (storeId) => {
45
+ const escapedStoreId = storeId.replaceAll(/[^a-zA-Z0-9_]/g, '_');
46
+ const tableName = `eventlog_${PERSISTENCE_FORMAT_VERSION}_${escapedStoreId}`;
47
+ if (tableName.length > 63) {
48
+ const hashedStoreId = Hash.string(storeId);
49
+ console.warn(`Table name is too long: "${tableName}". Postgres table names are limited to 63 characters. Using hashed storeId instead: "${hashedStoreId}".`);
50
+ return `eventlog_${PERSISTENCE_FORMAT_VERSION}_hash_${hashedStoreId}`;
51
+ }
52
+ return tableName;
53
+ };
54
+ /**
55
+ * CRITICAL: Increment this version whenever you modify the Postgres table schema structure.
56
+ *
57
+ * Bump required when:
58
+ * - Adding/removing/renaming columns in the eventlog table (see examples/web-todomvc-sync-electric/src/server/db.ts)
59
+ * - Changing column types or constraints
60
+ * - Modifying primary keys or indexes
61
+ *
62
+ * Bump NOT required when:
63
+ * - Changing query patterns or fetch logic
64
+ * - Adding new tables (as long as existing table schema remains unchanged)
65
+ * - Updating client-side implementation details
66
+ *
67
+ * Impact: Changing this version triggers a "soft reset" - new table names are created
68
+ * and old data becomes inaccessible (but remains in the database).
69
+ *
70
+ * Current schema (PostgreSQL):
71
+ * - seqNum (INTEGER PRIMARY KEY)
72
+ * - parentSeqNum (INTEGER)
73
+ * - name (TEXT NOT NULL)
74
+ * - args (JSONB NOT NULL)
75
+ * - clientId (TEXT NOT NULL)
76
+ * - sessionId (TEXT NOT NULL)
77
+ */
78
+ export const PERSISTENCE_FORMAT_VERSION = 6;
79
+ //# sourceMappingURL=make-electric-url.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"make-electric-url.js","sourceRoot":"","sources":["../src/make-electric-url.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@livestore/sync-electric",
3
- "version": "0.4.0-dev.2",
3
+ "version": "0.4.0-dev.21",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
7
7
  ".": "./dist/index.js"
8
8
  },
9
9
  "dependencies": {
10
- "@livestore/common": "0.4.0-dev.2",
11
- "@livestore/utils": "0.4.0-dev.2"
10
+ "@livestore/common": "0.4.0-dev.21",
11
+ "@livestore/utils": "0.4.0-dev.21"
12
12
  },
13
13
  "devDependencies": {},
14
14
  "files": [
package/src/api-schema.ts CHANGED
@@ -3,7 +3,7 @@ import { Schema } from '@livestore/utils/effect'
3
3
 
4
4
  export const PushPayload = Schema.TaggedStruct('@livestore/sync-electric.Push', {
5
5
  storeId: Schema.String,
6
- batch: Schema.Array(LiveStoreEvent.AnyEncodedGlobal),
6
+ batch: Schema.Array(LiveStoreEvent.Global.Encoded),
7
7
  }).annotations({ title: '@livestore/sync-electric.PushPayload' })
8
8
 
9
9
  export const PullPayload = Schema.TaggedStruct('@livestore/sync-electric.Pull', {
@@ -15,6 +15,10 @@ export const PullPayload = Schema.TaggedStruct('@livestore/sync-electric.Pull',
15
15
  handle: Schema.String,
16
16
  }),
17
17
  ),
18
+ live: Schema.Boolean,
18
19
  }).annotations({ title: '@livestore/sync-electric.PullPayload' })
19
20
 
20
21
  export const ApiPayload = Schema.Union(PullPayload, PushPayload)
22
+
23
+ // Format for the query params
24
+ export const ArgsSchema = Schema.compose(Schema.StringFromUriComponent, Schema.parseJson(PullPayload))
package/src/index.ts CHANGED
@@ -1,14 +1,15 @@
1
- import type { IsOfflineError, SyncBackend, SyncBackendConstructor } from '@livestore/common'
2
- import { InvalidPullError, InvalidPushError, UnexpectedError } from '@livestore/common'
1
+ import { InvalidPullError, InvalidPushError, type IsOfflineError, SyncBackend, UnknownError } from '@livestore/common'
3
2
  import { LiveStoreEvent } from '@livestore/common/schema'
4
- import { notYetImplemented, shouldNeverHappen } from '@livestore/utils'
3
+ import { notYetImplemented } from '@livestore/utils'
5
4
  import {
6
- Chunk,
5
+ type Duration,
7
6
  Effect,
8
7
  HttpClient,
9
8
  HttpClientRequest,
10
9
  HttpClientResponse,
11
10
  Option,
11
+ ReadonlyArray,
12
+ Schedule,
12
13
  Schema,
13
14
  Stream,
14
15
  SubscriptionRef,
@@ -16,7 +17,13 @@ import {
16
17
 
17
18
  import * as ApiSchema from './api-schema.ts'
18
19
 
20
+ export class InvalidOperationError extends Schema.TaggedError<InvalidOperationError>()('InvalidOperationError', {
21
+ operation: Schema.Literal('delete', 'update'),
22
+ message: Schema.String,
23
+ }) {}
24
+
19
25
  export * as ApiSchema from './api-schema.ts'
26
+ export * from './make-electric-url.ts'
20
27
 
21
28
  /*
22
29
  Example data:
@@ -65,27 +72,31 @@ const LiveStoreEventGlobalFromStringRecord = Schema.Struct({
65
72
  args: Schema.parseJson(Schema.Any),
66
73
  clientId: Schema.String,
67
74
  sessionId: Schema.String,
68
- }).pipe(
69
- Schema.transform(LiveStoreEvent.AnyEncodedGlobal, {
70
- decode: (_) => _,
71
- encode: (_) => _,
72
- }),
73
- )
74
-
75
- const ResponseItem = Schema.Struct({
75
+ })
76
+ .pipe(Schema.compose(LiveStoreEvent.Global.Encoded))
77
+ .annotations({ title: '@livestore/sync-electric:LiveStoreEventGlobalFromStringRecord' })
78
+
79
+ const ResponseItemInsert = Schema.Struct({
76
80
  /** Postgres path (e.g. `"public"."events_9069baf0_b3e6_42f7_980f_188416eab3fx3"/"0"`) */
77
81
  key: Schema.optional(Schema.String),
78
- value: Schema.optional(LiveStoreEventGlobalFromStringRecord),
79
- headers: Schema.Union(
80
- Schema.Struct({
81
- operation: Schema.Union(Schema.Literal('insert'), Schema.Literal('update'), Schema.Literal('delete')),
82
- relation: Schema.Array(Schema.String),
83
- }),
84
- Schema.Struct({
85
- control: Schema.String,
86
- }),
87
- ),
88
- })
82
+ value: LiveStoreEventGlobalFromStringRecord,
83
+ headers: Schema.Struct({ operation: Schema.Literal('insert'), relation: Schema.Array(Schema.String) }),
84
+ }).annotations({ title: '@livestore/sync-electric:ResponseItemInsert' })
85
+
86
+ const ResponseItemInvalid = Schema.Struct({
87
+ /** Postgres path (e.g. `"public"."events_9069baf0_b3e6_42f7_980f_188416eab3fx3"/"0"`) */
88
+ key: Schema.optional(Schema.String),
89
+ value: Schema.Any,
90
+ headers: Schema.Struct({ operation: Schema.Literal('update', 'delete'), relation: Schema.Array(Schema.String) }),
91
+ }).annotations({ title: '@livestore/sync-electric:ResponseItemInvalid' })
92
+
93
+ const ResponseItemControl = Schema.Struct({
94
+ key: Schema.optional(Schema.String),
95
+ value: Schema.optional(Schema.Any),
96
+ headers: Schema.Struct({ control: Schema.String }),
97
+ }).annotations({ title: '@livestore/sync-electric:ResponseItemControl' })
98
+
99
+ const ResponseItem = Schema.Union(ResponseItemInsert, ResponseItemInvalid, ResponseItemControl)
89
100
 
90
101
  const ResponseHeaders = Schema.Struct({
91
102
  'electric-handle': Schema.String,
@@ -94,77 +105,8 @@ const ResponseHeaders = Schema.Struct({
94
105
  'electric-offset': Schema.String,
95
106
  })
96
107
 
97
- export const syncBackend = {} as any
98
-
99
108
  export const syncBackendOptions = <TOptions extends SyncBackendOptions>(options: TOptions) => options
100
109
 
101
- /**
102
- * This function should be called in a trusted environment (e.g. a proxy server) as it
103
- * requires access to senstive information (e.g. `apiSecret` / `sourceSecret`).
104
- */
105
- export const makeElectricUrl = ({
106
- electricHost,
107
- searchParams: providedSearchParams,
108
- sourceId,
109
- sourceSecret,
110
- apiSecret,
111
- }: {
112
- electricHost: string
113
- /**
114
- * Needed to extract information from the search params which the `@livestore/sync-electric`
115
- * client implementation automatically adds:
116
- * - `handle`: the ElectricSQL handle
117
- * - `storeId`: the Livestore storeId
118
- */
119
- searchParams: URLSearchParams
120
- /** Needed for Electric Cloud */
121
- sourceId?: string
122
- /** Needed for Electric Cloud */
123
- sourceSecret?: string
124
- /** For self-hosted ElectricSQL */
125
- apiSecret?: string
126
- }) => {
127
- const endpointUrl = `${electricHost}/v1/shape`
128
- const argsResult = Schema.decodeUnknownEither(Schema.Struct({ args: Schema.parseJson(ApiSchema.PullPayload) }))(
129
- Object.fromEntries(providedSearchParams.entries()),
130
- )
131
-
132
- if (argsResult._tag === 'Left') {
133
- return shouldNeverHappen(
134
- 'Invalid search params provided to makeElectricUrl',
135
- providedSearchParams,
136
- Object.fromEntries(providedSearchParams.entries()),
137
- )
138
- }
139
-
140
- const args = argsResult.right.args
141
- const tableName = toTableName(args.storeId)
142
- const searchParams = new URLSearchParams()
143
- searchParams.set('table', tableName)
144
- if (sourceId !== undefined) {
145
- searchParams.set('source_id', sourceId)
146
- }
147
- if (sourceSecret !== undefined) {
148
- searchParams.set('source_secret', sourceSecret)
149
- }
150
- if (apiSecret !== undefined) {
151
- searchParams.set('api_secret', apiSecret)
152
- }
153
- if (args.handle._tag === 'None') {
154
- searchParams.set('offset', '-1')
155
- } else {
156
- searchParams.set('offset', args.handle.value.offset)
157
- searchParams.set('handle', args.handle.value.handle)
158
- searchParams.set('live', 'true')
159
- }
160
-
161
- const payload = args.payload
162
-
163
- const url = `${endpointUrl}?${searchParams.toString()}`
164
-
165
- return { url, storeId: args.storeId, needsInit: args.handle._tag === 'None', payload }
166
- }
167
-
168
110
  export interface SyncBackendOptions {
169
111
  /**
170
112
  * The endpoint to pull/push events. Pull is a `GET` request, push is a `POST` request.
@@ -179,7 +121,25 @@ export interface SyncBackendOptions {
179
121
  | {
180
122
  push: string
181
123
  pull: string
124
+ ping: string
182
125
  }
126
+
127
+ ping?: {
128
+ /**
129
+ * @default true
130
+ */
131
+ enabled?: boolean
132
+ /**
133
+ * How long to wait for a ping response before timing out
134
+ * @default 10 seconds
135
+ */
136
+ requestTimeout?: Duration.DurationInput
137
+ /**
138
+ * How often to send ping requests
139
+ * @default 10 seconds
140
+ */
141
+ requestInterval?: Duration.DurationInput
142
+ }
183
143
  }
184
144
 
185
145
  export const SyncMetadata = Schema.Struct({
@@ -188,48 +148,94 @@ export const SyncMetadata = Schema.Struct({
188
148
  handle: Schema.String,
189
149
  })
190
150
 
191
- type SyncMetadata = {
192
- offset: string
193
- // TODO move this into some kind of "global" sync metadata as it's the same for each event
194
- handle: string
195
- }
151
+ export type SyncMetadata = typeof SyncMetadata.Type
196
152
 
153
+ /**
154
+ * Creates a sync backend that uses ElectricSQL for real-time event synchronization.
155
+ *
156
+ * ElectricSQL enables real-time sync by streaming PostgreSQL changes to clients.
157
+ * This backend handles push (inserting events) and pull (streaming events via Electric's
158
+ * shape-based sync protocol).
159
+ *
160
+ * The endpoint should typically be part of your API layer to handle authentication,
161
+ * rate limiting, and proxying requests to the Electric server.
162
+ *
163
+ * @example
164
+ * ```ts
165
+ * import { makeSyncBackend } from '@livestore/sync-electric'
166
+ *
167
+ * const adapter = makePersistedAdapter({
168
+ * sync: {
169
+ * backend: makeSyncBackend({
170
+ * endpoint: '/api/electric',
171
+ * }),
172
+ * },
173
+ * })
174
+ * ```
175
+ *
176
+ * @example
177
+ * ```ts
178
+ * // With separate endpoints for push/pull/ping
179
+ * const backend = makeSyncBackend({
180
+ * endpoint: {
181
+ * push: '/api/push-event',
182
+ * pull: '/api/pull-events',
183
+ * ping: '/api/ping',
184
+ * },
185
+ * ping: {
186
+ * enabled: true,
187
+ * requestInterval: 15_000, // 15 seconds
188
+ * },
189
+ * })
190
+ * ```
191
+ *
192
+ * @see https://livestore.dev/docs/sync/electric for setup guide
193
+ */
197
194
  export const makeSyncBackend =
198
- ({ endpoint }: SyncBackendOptions): SyncBackendConstructor<SyncMetadata> =>
195
+ ({ endpoint, ...options }: SyncBackendOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
199
196
  ({ storeId, payload }) =>
200
197
  Effect.gen(function* () {
201
- const isConnected = yield* SubscriptionRef.make(true)
198
+ const isConnected = yield* SubscriptionRef.make(false)
202
199
  const pullEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.pull
203
200
  const pushEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.push
201
+ const pingEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.ping
202
+
203
+ const httpClient = yield* HttpClient.HttpClient
204
204
 
205
- const pull = (
205
+ const runPull = (
206
206
  handle: Option.Option<SyncMetadata>,
207
+ { live }: { live: boolean },
207
208
  ): Effect.Effect<
208
209
  Option.Option<
209
210
  readonly [
210
- Chunk.Chunk<{
211
+ /** The batch of events */
212
+ ReadonlyArray<{
211
213
  metadata: Option.Option<SyncMetadata>
212
- eventEncoded: LiveStoreEvent.AnyEncodedGlobal
214
+ eventEncoded: LiveStoreEvent.Global.Encoded
213
215
  }>,
216
+ /** The next handle to use for the next pull */
214
217
  Option.Option<SyncMetadata>,
215
218
  ]
216
219
  >,
217
- InvalidPullError | IsOfflineError,
218
- HttpClient.HttpClient
220
+ InvalidPullError | IsOfflineError
219
221
  > =>
220
222
  Effect.gen(function* () {
221
- const argsJson = yield* Schema.encode(Schema.parseJson(ApiSchema.PullPayload))(
222
- ApiSchema.PullPayload.make({ storeId, handle, payload }),
223
+ const argsJson = yield* Schema.encode(ApiSchema.ArgsSchema)(
224
+ ApiSchema.PullPayload.make({ storeId, handle, payload, live }),
223
225
  )
224
226
  const url = `${pullEndpoint}?args=${argsJson}`
225
227
 
226
- const resp = yield* HttpClient.get(url)
228
+ const resp = yield* httpClient.get(url)
227
229
 
228
230
  if (resp.status === 401) {
229
231
  const body = yield* resp.text.pipe(Effect.catchAll(() => Effect.succeed('-')))
230
232
  return yield* InvalidPullError.make({
231
- message: `Unauthorized (401): Couldn't connect to ElectricSQL: ${body}`,
233
+ cause: new Error(`Unauthorized (401): Couldn't connect to ElectricSQL: ${body}`),
232
234
  })
235
+ } else if (resp.status === 400) {
236
+ // Electric returns 400 when table doesn't exist
237
+ // Return empty result for non-existent tables
238
+ return Option.some([[], Option.none()] as const)
233
239
  } else if (resp.status === 409) {
234
240
  // https://electric-sql.com/openapi.html#/paths/~1v1~1shape/get
235
241
  // {
@@ -243,8 +249,9 @@ export const makeSyncBackend =
243
249
  // until we found a new event, then, continue with the new handle
244
250
  return notYetImplemented(`Electric shape not found`)
245
251
  } else if (resp.status < 200 || resp.status >= 300) {
252
+ const body = yield* resp.text
246
253
  return yield* InvalidPullError.make({
247
- message: `Unexpected status code: ${resp.status}`,
254
+ cause: new Error(`Unexpected status code: ${resp.status}: ${body}`),
248
255
  })
249
256
  }
250
257
 
@@ -257,56 +264,105 @@ export const makeSyncBackend =
257
264
  // Electric completes the long-poll request after ~20 seconds with a 204 status
258
265
  // In this case we just retry where we left off
259
266
  if (resp.status === 204) {
260
- return Option.some([Chunk.empty(), Option.some(nextHandle)] as const)
267
+ return Option.some([[], Option.some(nextHandle)] as const)
261
268
  }
262
269
 
263
- const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem), {
270
+ const allItems = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem), {
264
271
  onExcessProperty: 'preserve',
265
272
  })(resp)
266
273
 
267
- const items = body
268
- .filter((item) => item.value !== undefined && (item.headers as any).operation === 'insert')
269
- .map((item) => ({
270
- metadata: Option.some({ offset: nextHandle.offset!, handle: nextHandle.handle }),
271
- eventEncoded: item.value! as LiveStoreEvent.AnyEncodedGlobal,
272
- }))
274
+ // Check for delete/update operations and throw descriptive error
275
+ const invalidOperations = ReadonlyArray.filterMap(allItems, (item) =>
276
+ Schema.is(ResponseItemInvalid)(item) ? Option.some(item.headers.operation) : Option.none(),
277
+ )
273
278
 
274
- // // TODO implement proper `remaining` handling
275
- // remaining: 0,
279
+ if (invalidOperations.length > 0) {
280
+ const operation = invalidOperations[0]!
281
+ return yield* new InvalidOperationError({
282
+ operation,
283
+ message: `ElectricSQL '${operation}' event received. This results from directly mutating the event log. Append a series of events that produce the desired state instead of mutating the event log.`,
284
+ })
285
+ }
286
+
287
+ const items = allItems.filter(Schema.is(ResponseItemInsert)).map((item) => ({
288
+ metadata: Option.some({ offset: nextHandle.offset, handle: nextHandle.handle }),
289
+ eventEncoded: item.value as LiveStoreEvent.Global.Encoded,
290
+ }))
276
291
 
277
- // if (listenForNew === false && items.length === 0) {
278
- // return Option.none()
279
- // }
292
+ yield* Effect.annotateCurrentSpan({ itemsCount: items.length, nextHandle })
280
293
 
281
- return Option.some([Chunk.fromIterable(items), Option.some(nextHandle)] as const)
294
+ return Option.some([items, Option.some(nextHandle)] as const)
282
295
  }).pipe(
283
296
  Effect.scoped,
284
- Effect.mapError((cause) =>
285
- cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ message: cause.toString() }),
286
- ),
297
+ Effect.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
298
+ Effect.withSpan('electric-provider:runPull', { attributes: { handle, live } }),
287
299
  )
288
300
 
289
301
  const pullEndpointHasSameOrigin =
290
302
  pullEndpoint.startsWith('/') ||
291
303
  (globalThis.location !== undefined && globalThis.location.origin === new URL(pullEndpoint).origin)
292
304
 
293
- return {
294
- // If the pull endpoint has the same origin as the current page, we can assume that we already have a connection
295
- // otherwise we send a HEAD request to speed up the connection process
296
- connect: pullEndpointHasSameOrigin
297
- ? Effect.void
298
- : HttpClient.head(pullEndpoint).pipe(UnexpectedError.mapToUnexpectedError),
299
- pull: (args) =>
300
- Stream.unfoldChunkEffect(
301
- args.pipe(
302
- Option.map((_) => _.metadata),
303
- Option.flatten,
304
- ),
305
- (metadataOption) => pull(metadataOption),
305
+ const pingTimeout = options.ping?.requestTimeout ?? 10_000
306
+
307
+ const ping: SyncBackend.SyncBackend<SyncMetadata>['ping'] = Effect.gen(function* () {
308
+ yield* httpClient.pipe(HttpClient.filterStatusOk).head(pingEndpoint)
309
+
310
+ yield* SubscriptionRef.set(isConnected, true)
311
+ }).pipe(
312
+ UnknownError.mapToUnknownError,
313
+ Effect.timeout(pingTimeout),
314
+ Effect.catchTag('TimeoutException', () => SubscriptionRef.set(isConnected, false)),
315
+ Effect.withSpan('electric-provider:ping'),
316
+ )
317
+
318
+ const pingInterval = options.ping?.requestInterval ?? 10_000
319
+
320
+ if (options.ping?.enabled !== false) {
321
+ // Automatically ping the server to keep the connection alive
322
+ yield* ping.pipe(Effect.repeat(Schedule.spaced(pingInterval)), Effect.tapCauseLogPretty, Effect.forkScoped)
323
+ }
324
+
325
+ // If the pull endpoint has the same origin as the current page, we can assume that we already have a connection
326
+ // otherwise we send a HEAD request to speed up the connection process
327
+ const connect: SyncBackend.SyncBackend<SyncMetadata>['connect'] = pullEndpointHasSameOrigin
328
+ ? Effect.void
329
+ : ping.pipe(UnknownError.mapToUnknownError)
330
+
331
+ return SyncBackend.of({
332
+ connect,
333
+ pull: (cursor, options) => {
334
+ let hasEmittedAtLeastOnce = false
335
+
336
+ return Stream.unfoldEffect(cursor.pipe(Option.flatMap((_) => _.metadata)), (metadataOption) =>
337
+ Effect.gen(function* () {
338
+ const result = yield* runPull(metadataOption, { live: options?.live ?? false })
339
+ if (Option.isNone(result)) return Option.none()
340
+
341
+ const [batch, nextMetadataOption] = result.value
342
+
343
+ // Continue pagination if we have data
344
+ if (batch.length > 0) {
345
+ hasEmittedAtLeastOnce = true
346
+ return Option.some([{ batch, hasMore: true }, nextMetadataOption])
347
+ }
348
+
349
+ // Make sure we emit at least once even if there's no data or we're live-pulling
350
+ if (hasEmittedAtLeastOnce === false || options?.live) {
351
+ hasEmittedAtLeastOnce = true
352
+ return Option.some([{ batch, hasMore: false }, nextMetadataOption])
353
+ }
354
+
355
+ // Stop on empty batch (when not live)
356
+ return Option.none()
357
+ }),
306
358
  ).pipe(
307
- Stream.chunks,
308
- Stream.map((chunk) => ({ batch: [...chunk], remaining: 0 })),
309
- ),
359
+ Stream.map(({ batch, hasMore }) => ({
360
+ batch,
361
+ pageInfo: hasMore ? SyncBackend.pageInfoMoreUnknown : SyncBackend.pageInfoNoMore,
362
+ })),
363
+ Stream.withSpan('electric-provider:pull'),
364
+ )
365
+ },
310
366
 
311
367
  push: (batch) =>
312
368
  Effect.gen(function* () {
@@ -314,18 +370,17 @@ export const makeSyncBackend =
314
370
  HttpClientRequest.post(pushEndpoint),
315
371
  ApiSchema.PushPayload.make({ storeId, batch }),
316
372
  ).pipe(
317
- Effect.andThen(HttpClient.execute),
373
+ Effect.andThen(httpClient.pipe(HttpClient.filterStatusOk).execute),
318
374
  Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
319
375
  Effect.scoped,
320
- Effect.mapError((cause) =>
321
- InvalidPushError.make({ reason: { _tag: 'Unexpected', message: cause.toString() } }),
322
- ),
376
+ Effect.mapError((cause) => InvalidPushError.make({ cause: UnknownError.make({ cause }) })),
323
377
  )
324
378
 
325
379
  if (!resp.success) {
326
- yield* InvalidPushError.make({ reason: { _tag: 'Unexpected', message: 'Push failed' } })
380
+ return yield* InvalidPushError.make({ cause: new UnknownError({ cause: new Error('Push failed') }) })
327
381
  }
328
- }),
382
+ }).pipe(Effect.withSpan('electric-provider:push')),
383
+ ping,
329
384
  isConnected,
330
385
  metadata: {
331
386
  name: '@livestore/sync-electric',
@@ -333,17 +388,11 @@ export const makeSyncBackend =
333
388
  protocol: 'http',
334
389
  endpoint,
335
390
  },
336
- } satisfies SyncBackend<SyncMetadata>
391
+ supports: {
392
+ // Given Electric is heavily optimized for immutable caching, we can't know the remaining count
393
+ // until we've reached the end of the stream
394
+ pullPageInfoKnown: false,
395
+ pullLive: true,
396
+ },
397
+ })
337
398
  })
338
-
339
- /**
340
- * Needs to be bumped when the storage format changes (e.g. eventlogTable schema changes)
341
- *
342
- * Changing this version number will lead to a "soft reset".
343
- */
344
- export const PERSISTENCE_FORMAT_VERSION = 6
345
-
346
- export const toTableName = (storeId: string) => {
347
- const escapedStoreId = storeId.replaceAll(/[^a-zA-Z0-9_]/g, '_')
348
- return `eventlog_${PERSISTENCE_FORMAT_VERSION}_${escapedStoreId}`
349
- }