@livestore/sync-electric 0.4.0-dev.9 → 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.
package/src/index.ts CHANGED
@@ -1,10 +1,4 @@
1
- import {
2
- InvalidPullError,
3
- InvalidPushError,
4
- type IsOfflineError,
5
- SyncBackend,
6
- UnexpectedError,
7
- } from '@livestore/common'
1
+ import { type IsOfflineError, SyncBackend, UnknownError } from '@livestore/common'
8
2
  import { LiveStoreEvent } from '@livestore/common/schema'
9
3
  import { notYetImplemented } from '@livestore/utils'
10
4
  import {
@@ -23,8 +17,8 @@ import {
23
17
 
24
18
  import * as ApiSchema from './api-schema.ts'
25
19
 
26
- export class InvalidOperationError extends Schema.TaggedError<InvalidOperationError>()('InvalidOperationError', {
27
- operation: Schema.Union(Schema.Literal('delete'), Schema.Literal('update')),
20
+ export class InvalidOperationError extends Schema.TaggedError<InvalidOperationError>('~@livestore/sync-electric/InvalidOperationError')('InvalidOperationError', {
21
+ operation: Schema.Literal('delete', 'update'),
28
22
  message: Schema.String,
29
23
  }) {}
30
24
 
@@ -79,12 +73,7 @@ const LiveStoreEventGlobalFromStringRecord = Schema.Struct({
79
73
  clientId: Schema.String,
80
74
  sessionId: Schema.String,
81
75
  })
82
- .pipe(
83
- Schema.transform(LiveStoreEvent.AnyEncodedGlobal, {
84
- decode: (_) => _,
85
- encode: (_) => _,
86
- }),
87
- )
76
+ .pipe(Schema.compose(LiveStoreEvent.Global.Encoded))
88
77
  .annotations({ title: '@livestore/sync-electric:LiveStoreEventGlobalFromStringRecord' })
89
78
 
90
79
  const ResponseItemInsert = Schema.Struct({
@@ -161,6 +150,47 @@ export const SyncMetadata = Schema.Struct({
161
150
 
162
151
  export type SyncMetadata = typeof SyncMetadata.Type
163
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
+ */
164
194
  export const makeSyncBackend =
165
195
  ({ endpoint, ...options }: SyncBackendOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
166
196
  ({ storeId, payload }) =>
@@ -181,13 +211,13 @@ export const makeSyncBackend =
181
211
  /** The batch of events */
182
212
  ReadonlyArray<{
183
213
  metadata: Option.Option<SyncMetadata>
184
- eventEncoded: LiveStoreEvent.AnyEncodedGlobal
214
+ eventEncoded: LiveStoreEvent.Global.Encoded
185
215
  }>,
186
216
  /** The next handle to use for the next pull */
187
217
  Option.Option<SyncMetadata>,
188
218
  ]
189
219
  >,
190
- InvalidPullError | IsOfflineError
220
+ UnknownError | IsOfflineError
191
221
  > =>
192
222
  Effect.gen(function* () {
193
223
  const argsJson = yield* Schema.encode(ApiSchema.ArgsSchema)(
@@ -199,7 +229,7 @@ export const makeSyncBackend =
199
229
 
200
230
  if (resp.status === 401) {
201
231
  const body = yield* resp.text.pipe(Effect.catchAll(() => Effect.succeed('-')))
202
- return yield* InvalidPullError.make({
232
+ return yield* new UnknownError({
203
233
  cause: new Error(`Unauthorized (401): Couldn't connect to ElectricSQL: ${body}`),
204
234
  })
205
235
  } else if (resp.status === 400) {
@@ -220,9 +250,7 @@ export const makeSyncBackend =
220
250
  return notYetImplemented(`Electric shape not found`)
221
251
  } else if (resp.status < 200 || resp.status >= 300) {
222
252
  const body = yield* resp.text
223
- return yield* InvalidPullError.make({
224
- cause: new Error(`Unexpected status code: ${resp.status}: ${body}`),
225
- })
253
+ return yield* new UnknownError({ cause: new Error(`Unexpected status code: ${resp.status}: ${body}`) })
226
254
  }
227
255
 
228
256
  const headers = yield* HttpClientResponse.schemaHeaders(ResponseHeaders)(resp)
@@ -243,7 +271,7 @@ export const makeSyncBackend =
243
271
 
244
272
  // Check for delete/update operations and throw descriptive error
245
273
  const invalidOperations = ReadonlyArray.filterMap(allItems, (item) =>
246
- Schema.is(ResponseItemInvalid)(item) ? Option.some(item.headers.operation) : Option.none(),
274
+ Schema.is(ResponseItemInvalid)(item) === true ? Option.some(item.headers.operation) : Option.none(),
247
275
  )
248
276
 
249
277
  if (invalidOperations.length > 0) {
@@ -256,7 +284,7 @@ export const makeSyncBackend =
256
284
 
257
285
  const items = allItems.filter(Schema.is(ResponseItemInsert)).map((item) => ({
258
286
  metadata: Option.some({ offset: nextHandle.offset, handle: nextHandle.handle }),
259
- eventEncoded: item.value as LiveStoreEvent.AnyEncodedGlobal,
287
+ eventEncoded: item.value,
260
288
  }))
261
289
 
262
290
  yield* Effect.annotateCurrentSpan({ itemsCount: items.length, nextHandle })
@@ -264,7 +292,9 @@ export const makeSyncBackend =
264
292
  return Option.some([items, Option.some(nextHandle)] as const)
265
293
  }).pipe(
266
294
  Effect.scoped,
267
- Effect.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
295
+ Effect.mapError((cause) =>
296
+ cause._tag === 'UnknownError' ? cause : new UnknownError({ cause }),
297
+ ),
268
298
  Effect.withSpan('electric-provider:runPull', { attributes: { handle, live } }),
269
299
  )
270
300
 
@@ -279,7 +309,7 @@ export const makeSyncBackend =
279
309
 
280
310
  yield* SubscriptionRef.set(isConnected, true)
281
311
  }).pipe(
282
- UnexpectedError.mapToUnexpectedError,
312
+ UnknownError.mapToUnknownError,
283
313
  Effect.timeout(pingTimeout),
284
314
  Effect.catchTag('TimeoutException', () => SubscriptionRef.set(isConnected, false)),
285
315
  Effect.withSpan('electric-provider:ping'),
@@ -294,9 +324,8 @@ export const makeSyncBackend =
294
324
 
295
325
  // If the pull endpoint has the same origin as the current page, we can assume that we already have a connection
296
326
  // otherwise we send a HEAD request to speed up the connection process
297
- const connect: SyncBackend.SyncBackend<SyncMetadata>['connect'] = pullEndpointHasSameOrigin
298
- ? Effect.void
299
- : ping.pipe(UnexpectedError.mapToUnexpectedError)
327
+ const connect: SyncBackend.SyncBackend<SyncMetadata>['connect'] =
328
+ pullEndpointHasSameOrigin === true ? Effect.void : ping.pipe(UnknownError.mapToUnknownError)
300
329
 
301
330
  return SyncBackend.of({
302
331
  connect,
@@ -306,7 +335,7 @@ export const makeSyncBackend =
306
335
  return Stream.unfoldEffect(cursor.pipe(Option.flatMap((_) => _.metadata)), (metadataOption) =>
307
336
  Effect.gen(function* () {
308
337
  const result = yield* runPull(metadataOption, { live: options?.live ?? false })
309
- if (Option.isNone(result)) return Option.none()
338
+ if (Option.isNone(result) === true) return Option.none()
310
339
 
311
340
  const [batch, nextMetadataOption] = result.value
312
341
 
@@ -317,7 +346,7 @@ export const makeSyncBackend =
317
346
  }
318
347
 
319
348
  // Make sure we emit at least once even if there's no data or we're live-pulling
320
- if (hasEmittedAtLeastOnce === false || options?.live) {
349
+ if (hasEmittedAtLeastOnce === false || options?.live === true) {
321
350
  hasEmittedAtLeastOnce = true
322
351
  return Option.some([{ batch, hasMore: false }, nextMetadataOption])
323
352
  }
@@ -328,28 +357,27 @@ export const makeSyncBackend =
328
357
  ).pipe(
329
358
  Stream.map(({ batch, hasMore }) => ({
330
359
  batch,
331
- pageInfo: hasMore ? SyncBackend.pageInfoMoreUnknown : SyncBackend.pageInfoNoMore,
360
+ pageInfo: hasMore === true ? SyncBackend.pageInfoMoreUnknown : SyncBackend.pageInfoNoMore,
332
361
  })),
333
362
  Stream.withSpan('electric-provider:pull'),
334
363
  )
335
364
  },
336
365
 
337
- push: (batch) =>
338
- Effect.gen(function* () {
339
- const resp = yield* HttpClientRequest.schemaBodyJson(ApiSchema.PushPayload)(
340
- HttpClientRequest.post(pushEndpoint),
341
- ApiSchema.PushPayload.make({ storeId, batch }),
342
- ).pipe(
343
- Effect.andThen(httpClient.pipe(HttpClient.filterStatusOk).execute),
344
- Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
345
- Effect.scoped,
346
- Effect.mapError((cause) => InvalidPushError.make({ cause: UnexpectedError.make({ cause }) })),
347
- )
348
-
349
- if (!resp.success) {
350
- return yield* InvalidPushError.make({ cause: new UnexpectedError({ cause: new Error('Push failed') }) })
351
- }
352
- }).pipe(Effect.withSpan('electric-provider:push')),
366
+ push: Effect.fn('electric-provider:push')(function* (batch) {
367
+ const resp = yield* HttpClientRequest.schemaBodyJson(ApiSchema.PushPayload)(
368
+ HttpClientRequest.post(pushEndpoint),
369
+ ApiSchema.PushPayload.make({ storeId, batch }),
370
+ ).pipe(
371
+ Effect.andThen(httpClient.pipe(HttpClient.filterStatusOk).execute),
372
+ Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
373
+ Effect.scoped,
374
+ Effect.mapError((cause) => UnknownError.make({ cause })),
375
+ )
376
+
377
+ if (resp.success === false) {
378
+ return yield* new UnknownError({ cause: new Error('Push failed') })
379
+ }
380
+ }),
353
381
  ping,
354
382
  isConnected,
355
383
  metadata: {
@@ -1,5 +1,6 @@
1
1
  import { shouldNeverHappen } from '@livestore/utils'
2
2
  import { Hash, Schema } from '@livestore/utils/effect'
3
+
3
4
  import * as ApiSchema from './api-schema.ts'
4
5
 
5
6
  /**
@@ -75,7 +76,7 @@ export const makeElectricUrl = ({
75
76
  } else {
76
77
  searchParams.set('offset', args.handle.value.offset)
77
78
  searchParams.set('handle', args.handle.value.handle)
78
- searchParams.set('live', args.live ? 'true' : 'false')
79
+ searchParams.set('live', args.live === true ? 'true' : 'false')
79
80
  }
80
81
 
81
82
  const payload = args.payload
@@ -103,8 +104,27 @@ export const toTableName = (storeId: string) => {
103
104
  }
104
105
 
105
106
  /**
106
- * Needs to be bumped when the storage format changes (e.g. eventlogTable schema changes)
107
+ * CRITICAL: Increment this version whenever you modify the Postgres table schema structure.
108
+ *
109
+ * Bump required when:
110
+ * - Adding/removing/renaming columns in the eventlog table (see examples/web-todomvc-sync-electric/src/server/db.ts)
111
+ * - Changing column types or constraints
112
+ * - Modifying primary keys or indexes
113
+ *
114
+ * Bump NOT required when:
115
+ * - Changing query patterns or fetch logic
116
+ * - Adding new tables (as long as existing table schema remains unchanged)
117
+ * - Updating client-side implementation details
118
+ *
119
+ * Impact: Changing this version triggers a "soft reset" - new table names are created
120
+ * and old data becomes inaccessible (but remains in the database).
107
121
  *
108
- * Changing this version number will lead to a "soft reset".
122
+ * Current schema (PostgreSQL):
123
+ * - seqNum (INTEGER PRIMARY KEY)
124
+ * - parentSeqNum (INTEGER)
125
+ * - name (TEXT NOT NULL)
126
+ * - args (JSONB NOT NULL)
127
+ * - clientId (TEXT NOT NULL)
128
+ * - sessionId (TEXT NOT NULL)
109
129
  */
110
130
  export const PERSISTENCE_FORMAT_VERSION = 6