@livestore/sync-electric 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.
@@ -1,5 +1,5 @@
1
1
  import { shouldNeverHappen } from '@livestore/utils';
2
- import { Hash, Schema } from '@livestore/utils/effect';
2
+ import { Hash, Result, Schema } from '@livestore/utils/effect';
3
3
  import * as ApiSchema from "./api-schema.js";
4
4
  /**
5
5
  * This function should be called in a trusted environment (e.g. a proxy server) as it
@@ -8,11 +8,11 @@ import * as ApiSchema from "./api-schema.js";
8
8
  export const makeElectricUrl = ({ electricHost, searchParams: providedSearchParams, sourceId, sourceSecret, apiSecret, }) => {
9
9
  const endpointUrl = `${electricHost}/v1/shape`;
10
10
  const UrlParamsSchema = Schema.Struct({ args: ApiSchema.ArgsSchema });
11
- const argsResult = Schema.decodeUnknownEither(UrlParamsSchema)(Object.fromEntries(providedSearchParams.entries()));
12
- if (argsResult._tag === 'Left') {
11
+ const argsResult = Schema.decodeUnknownResult(UrlParamsSchema)(Object.fromEntries(providedSearchParams.entries()));
12
+ if (Result.isFailure(argsResult) === true) {
13
13
  return shouldNeverHappen('Invalid search params provided to makeElectricUrl', providedSearchParams, Object.fromEntries(providedSearchParams.entries()));
14
14
  }
15
- const args = argsResult.right.args;
15
+ const args = argsResult.success.args;
16
16
  const tableName = toTableName(args.storeId);
17
17
  // TODO refactor with Effect URLSearchParams schema
18
18
  // https://electric-sql.com/openapi.html
@@ -35,7 +35,7 @@ export const makeElectricUrl = ({ electricHost, searchParams: providedSearchPara
35
35
  else {
36
36
  searchParams.set('offset', args.handle.value.offset);
37
37
  searchParams.set('handle', args.handle.value.handle);
38
- searchParams.set('live', args.live ? 'true' : 'false');
38
+ searchParams.set('live', args.live === true ? 'true' : 'false');
39
39
  }
40
40
  const payload = args.payload;
41
41
  const url = `${endpointUrl}?${searchParams.toString()}`;
@@ -52,9 +52,28 @@ export const toTableName = (storeId) => {
52
52
  return tableName;
53
53
  };
54
54
  /**
55
- * Needs to be bumped when the storage format changes (e.g. eventlogTable schema changes)
55
+ * CRITICAL: Increment this version whenever you modify the Postgres table schema structure.
56
56
  *
57
- * Changing this version number will lead to a "soft reset".
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)
58
77
  */
59
78
  export const PERSISTENCE_FORMAT_VERSION = 6;
60
79
  //# sourceMappingURL=make-electric-url.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"make-electric-url.js","sourceRoot":"","sources":["../src/make-electric-url.ts"],"names":[],"mappings":"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"}
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,25 +1,64 @@
1
1
  {
2
2
  "name": "@livestore/sync-electric",
3
- "version": "0.4.0-dev.9",
3
+ "version": "0.5.0-dev.0",
4
+ "license": "Apache-2.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/livestorejs/livestore-contrib.git"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "package.json",
12
+ "src"
13
+ ],
4
14
  "type": "module",
5
15
  "sideEffects": false,
6
16
  "exports": {
7
17
  ".": "./dist/index.js"
8
18
  },
9
- "dependencies": {
10
- "@livestore/common": "0.4.0-dev.9",
11
- "@livestore/utils": "0.4.0-dev.9"
12
- },
13
- "devDependencies": {},
14
- "files": [
15
- "package.json",
16
- "src",
17
- "dist"
18
- ],
19
- "license": "Apache-2.0",
20
19
  "publishConfig": {
21
20
  "access": "public"
22
21
  },
22
+ "dependencies": {
23
+ "@livestore/common": "0.5.0-dev.0",
24
+ "@livestore/utils": "0.5.0-dev.0"
25
+ },
26
+ "devDependencies": {
27
+ "@effect/opentelemetry": "4.0.0-rc.111",
28
+ "@effect/platform-browser": "4.0.0-rc.111",
29
+ "@effect/platform-bun": "4.0.0-rc.111",
30
+ "@effect/platform-node": "4.0.0-rc.111",
31
+ "@effect/platform-node-shared": "4.0.0-rc.111",
32
+ "@effect/vitest": "4.0.0-rc.111",
33
+ "@opentelemetry/api": "1.9.0",
34
+ "@opentelemetry/resources": "2.2.0",
35
+ "@standard-schema/spec": "1.1.0",
36
+ "effect": "4.0.0-rc.111",
37
+ "vitest": "4.1.9"
38
+ },
39
+ "peerDependencies": {
40
+ "@effect/opentelemetry": "^4.0.0-rc.111",
41
+ "@effect/platform-browser": "^4.0.0-rc.111",
42
+ "@effect/platform-bun": "^4.0.0-rc.111",
43
+ "@effect/platform-node": "^4.0.0-rc.111",
44
+ "@effect/platform-node-shared": "^4.0.0-rc.111",
45
+ "@effect/vitest": "^4.0.0-rc.111",
46
+ "@opentelemetry/api": "^1.9.0",
47
+ "@opentelemetry/resources": "^2.2.0",
48
+ "@standard-schema/spec": "^1.1.0",
49
+ "effect": "^4.0.0-rc.111"
50
+ },
51
+ "$genie": {
52
+ "source": "package.json.genie.ts",
53
+ "warning": "DO NOT EDIT - changes will be overwritten",
54
+ "workspaceClosureDirs": [
55
+ "packages/@livestore/sync-electric",
56
+ "repos/livestore/packages/@livestore/common",
57
+ "repos/livestore/packages/@livestore/utils",
58
+ "repos/livestore/packages/@livestore/utils-dev",
59
+ "repos/livestore/packages/@livestore/webmesh"
60
+ ]
61
+ },
23
62
  "scripts": {
24
63
  "build": "",
25
64
  "test": "echo 'No tests yet'"
@@ -0,0 +1,49 @@
1
+ import { Option, Schema } from '@livestore/utils/effect'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import * as ApiSchema from './api-schema.ts'
5
+ import { makeElectricUrl } from './make-electric-url.ts'
6
+
7
+ const HandleStruct = { offset: 'off-1', handle: 'h-1' } as const
8
+
9
+ const cases = [
10
+ { name: 'none + no payload', storeId: 's1', payload: undefined, handle: Option.none(), live: false },
11
+ { name: 'some + payload', storeId: 's2', payload: { foo: 'bar' }, handle: Option.some(HandleStruct), live: true },
12
+ { name: 'none + payload present', storeId: 's3', payload: { a: 1 }, handle: Option.none(), live: false },
13
+ { name: 'some + no payload', storeId: 's4', payload: undefined, handle: Option.some(HandleStruct), live: true },
14
+ { name: 'none + null payload', storeId: 's5', payload: null, handle: Option.none(), live: false },
15
+ ] as const
16
+
17
+ describe('sync-electric ArgsSchema round-trip', () => {
18
+ for (const c of cases) {
19
+ it(`round-trips ${c.name}`, () => {
20
+ const input = ApiSchema.PullPayload.make({
21
+ storeId: c.storeId,
22
+ payload: c.payload,
23
+ handle: c.handle,
24
+ live: c.live,
25
+ })
26
+
27
+ const encoded = Schema.encodeSync(ApiSchema.ArgsSchema)(input)
28
+ expect(typeof encoded).toBe('string')
29
+
30
+ const decoded = Schema.decodeUnknownSync(ApiSchema.ArgsSchema)(encoded)
31
+
32
+ expect(Option.isOption(decoded.handle)).toBe(true)
33
+ expect(decoded.handle._tag).toBe(c.handle._tag)
34
+ if (Option.isSome(c.handle)) {
35
+ expect((decoded.handle as Option.Some<typeof HandleStruct>).value).toEqual(HandleStruct)
36
+ }
37
+ expect(decoded.payload).toEqual(c.payload)
38
+ expect(decoded.storeId).toBe(c.storeId)
39
+ expect(decoded.live).toBe(c.live)
40
+
41
+ // Full path through makeElectricUrl: URLSearchParams -> Struct({ args }) -> ArgsSchema
42
+ const searchParams = new URLSearchParams({ args: encoded })
43
+ const result = makeElectricUrl({ electricHost: 'http://electric.test', searchParams })
44
+ expect(result.storeId).toBe(c.storeId)
45
+ expect(result.needsInit).toBe(Option.isNone(c.handle))
46
+ expect(result.payload).toEqual(c.payload)
47
+ })
48
+ }
49
+ })
package/src/api-schema.ts CHANGED
@@ -3,22 +3,30 @@ 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),
7
- }).annotations({ title: '@livestore/sync-electric.PushPayload' })
6
+ batch: Schema.Array(LiveStoreEvent.Global.Encoded),
7
+ }).annotate({ title: '@livestore/sync-electric.PushPayload' })
8
8
 
9
9
  export const PullPayload = Schema.TaggedStruct('@livestore/sync-electric.Pull', {
10
10
  storeId: Schema.String,
11
- payload: Schema.UndefinedOr(Schema.JsonValue),
12
- handle: Schema.Option(
13
- Schema.Struct({
14
- offset: Schema.String,
15
- handle: Schema.String,
16
- }),
11
+ // `optional` so an absent payload is omitted from the JSON entirely (a required key with an
12
+ // `undefined` value would be dropped by `JSON.stringify` and then fail decoding as "Missing key").
13
+ payload: Schema.optional(Schema.Json),
14
+ // `toCodecJson` makes the `Option` encode to its JSON-safe struct form (`{ _tag: "None" }` /
15
+ // `{ _tag: "Some", value }`). A bare `Schema.Option` encodes to a runtime `Option`, whose
16
+ // `toJSON` leaks `{"_id":"Option",...}` when `fromJsonString` runs `JSON.stringify`, and that
17
+ // shape then fails to decode back into an `Option`.
18
+ handle: Schema.toCodecJson(
19
+ Schema.Option(
20
+ Schema.Struct({
21
+ offset: Schema.String,
22
+ handle: Schema.String,
23
+ }),
24
+ ),
17
25
  ),
18
26
  live: Schema.Boolean,
19
- }).annotations({ title: '@livestore/sync-electric.PullPayload' })
27
+ }).annotate({ title: '@livestore/sync-electric.PullPayload' })
20
28
 
21
- export const ApiPayload = Schema.Union(PullPayload, PushPayload)
29
+ export const ApiPayload = Schema.Union([PullPayload, PushPayload])
22
30
 
23
31
  // Format for the query params
24
- export const ArgsSchema = Schema.compose(Schema.StringFromUriComponent, Schema.parseJson(PullPayload))
32
+ export const ArgsSchema = Schema.StringFromUriComponent.pipe(Schema.decodeTo(Schema.fromJsonString(PullPayload)))
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 {
@@ -15,6 +9,7 @@ import {
15
9
  HttpClientResponse,
16
10
  Option,
17
11
  ReadonlyArray,
12
+ Result,
18
13
  Schedule,
19
14
  Schema,
20
15
  Stream,
@@ -23,8 +18,10 @@ import {
23
18
 
24
19
  import * as ApiSchema from './api-schema.ts'
25
20
 
26
- export class InvalidOperationError extends Schema.TaggedError<InvalidOperationError>()('InvalidOperationError', {
27
- operation: Schema.Union(Schema.Literal('delete'), Schema.Literal('update')),
21
+ export class InvalidOperationError extends Schema.TaggedError<InvalidOperationError>(
22
+ '~@livestore/sync-electric/InvalidOperationError',
23
+ )('InvalidOperationError', {
24
+ operation: Schema.Literals(['delete', 'update']),
28
25
  message: Schema.String,
29
26
  }) {}
30
27
 
@@ -75,39 +72,34 @@ const LiveStoreEventGlobalFromStringRecord = Schema.Struct({
75
72
  seqNum: Schema.NumberFromString,
76
73
  parentSeqNum: Schema.NumberFromString,
77
74
  name: Schema.String,
78
- args: Schema.parseJson(Schema.Any),
75
+ args: Schema.fromJsonString(Schema.Any),
79
76
  clientId: Schema.String,
80
77
  sessionId: Schema.String,
81
78
  })
82
- .pipe(
83
- Schema.transform(LiveStoreEvent.AnyEncodedGlobal, {
84
- decode: (_) => _,
85
- encode: (_) => _,
86
- }),
87
- )
88
- .annotations({ title: '@livestore/sync-electric:LiveStoreEventGlobalFromStringRecord' })
79
+ .pipe(Schema.decodeTo(LiveStoreEvent.Global.Encoded))
80
+ .annotate({ title: '@livestore/sync-electric:LiveStoreEventGlobalFromStringRecord' })
89
81
 
90
82
  const ResponseItemInsert = Schema.Struct({
91
83
  /** Postgres path (e.g. `"public"."events_9069baf0_b3e6_42f7_980f_188416eab3fx3"/"0"`) */
92
84
  key: Schema.optional(Schema.String),
93
85
  value: LiveStoreEventGlobalFromStringRecord,
94
86
  headers: Schema.Struct({ operation: Schema.Literal('insert'), relation: Schema.Array(Schema.String) }),
95
- }).annotations({ title: '@livestore/sync-electric:ResponseItemInsert' })
87
+ }).annotate({ title: '@livestore/sync-electric:ResponseItemInsert' })
96
88
 
97
89
  const ResponseItemInvalid = Schema.Struct({
98
90
  /** Postgres path (e.g. `"public"."events_9069baf0_b3e6_42f7_980f_188416eab3fx3"/"0"`) */
99
91
  key: Schema.optional(Schema.String),
100
92
  value: Schema.Any,
101
- headers: Schema.Struct({ operation: Schema.Literal('update', 'delete'), relation: Schema.Array(Schema.String) }),
102
- }).annotations({ title: '@livestore/sync-electric:ResponseItemInvalid' })
93
+ headers: Schema.Struct({ operation: Schema.Literals(['update', 'delete']), relation: Schema.Array(Schema.String) }),
94
+ }).annotate({ title: '@livestore/sync-electric:ResponseItemInvalid' })
103
95
 
104
96
  const ResponseItemControl = Schema.Struct({
105
97
  key: Schema.optional(Schema.String),
106
98
  value: Schema.optional(Schema.Any),
107
99
  headers: Schema.Struct({ control: Schema.String }),
108
- }).annotations({ title: '@livestore/sync-electric:ResponseItemControl' })
100
+ }).annotate({ title: '@livestore/sync-electric:ResponseItemControl' })
109
101
 
110
- const ResponseItem = Schema.Union(ResponseItemInsert, ResponseItemInvalid, ResponseItemControl)
102
+ const ResponseItem = Schema.Union([ResponseItemInsert, ResponseItemInvalid, ResponseItemControl])
111
103
 
112
104
  const ResponseHeaders = Schema.Struct({
113
105
  'electric-handle': Schema.String,
@@ -144,12 +136,12 @@ export interface SyncBackendOptions {
144
136
  * How long to wait for a ping response before timing out
145
137
  * @default 10 seconds
146
138
  */
147
- requestTimeout?: Duration.DurationInput
139
+ requestTimeout?: Duration.Input
148
140
  /**
149
141
  * How often to send ping requests
150
142
  * @default 10 seconds
151
143
  */
152
- requestInterval?: Duration.DurationInput
144
+ requestInterval?: Duration.Input
153
145
  }
154
146
  }
155
147
 
@@ -161,6 +153,47 @@ export const SyncMetadata = Schema.Struct({
161
153
 
162
154
  export type SyncMetadata = typeof SyncMetadata.Type
163
155
 
156
+ /**
157
+ * Creates a sync backend that uses ElectricSQL for real-time event synchronization.
158
+ *
159
+ * ElectricSQL enables real-time sync by streaming PostgreSQL changes to clients.
160
+ * This backend handles push (inserting events) and pull (streaming events via Electric's
161
+ * shape-based sync protocol).
162
+ *
163
+ * The endpoint should typically be part of your API layer to handle authentication,
164
+ * rate limiting, and proxying requests to the Electric server.
165
+ *
166
+ * @example
167
+ * ```ts
168
+ * import { makeSyncBackend } from '@livestore/sync-electric'
169
+ *
170
+ * const adapter = makePersistedAdapter({
171
+ * sync: {
172
+ * backend: makeSyncBackend({
173
+ * endpoint: '/api/electric',
174
+ * }),
175
+ * },
176
+ * })
177
+ * ```
178
+ *
179
+ * @example
180
+ * ```ts
181
+ * // With separate endpoints for push/pull/ping
182
+ * const backend = makeSyncBackend({
183
+ * endpoint: {
184
+ * push: '/api/push-event',
185
+ * pull: '/api/pull-events',
186
+ * ping: '/api/ping',
187
+ * },
188
+ * ping: {
189
+ * enabled: true,
190
+ * requestInterval: 15_000, // 15 seconds
191
+ * },
192
+ * })
193
+ * ```
194
+ *
195
+ * @see https://livestore.dev/docs/sync/electric for setup guide
196
+ */
164
197
  export const makeSyncBackend =
165
198
  ({ endpoint, ...options }: SyncBackendOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
166
199
  ({ storeId, payload }) =>
@@ -181,16 +214,16 @@ export const makeSyncBackend =
181
214
  /** The batch of events */
182
215
  ReadonlyArray<{
183
216
  metadata: Option.Option<SyncMetadata>
184
- eventEncoded: LiveStoreEvent.AnyEncodedGlobal
217
+ eventEncoded: LiveStoreEvent.Global.Encoded
185
218
  }>,
186
219
  /** The next handle to use for the next pull */
187
220
  Option.Option<SyncMetadata>,
188
221
  ]
189
222
  >,
190
- InvalidPullError | IsOfflineError
223
+ UnknownError | IsOfflineError
191
224
  > =>
192
225
  Effect.gen(function* () {
193
- const argsJson = yield* Schema.encode(ApiSchema.ArgsSchema)(
226
+ const argsJson = yield* Schema.encodeEffect(ApiSchema.ArgsSchema)(
194
227
  ApiSchema.PullPayload.make({ storeId, handle, payload, live }),
195
228
  )
196
229
  const url = `${pullEndpoint}?args=${argsJson}`
@@ -198,8 +231,8 @@ export const makeSyncBackend =
198
231
  const resp = yield* httpClient.get(url)
199
232
 
200
233
  if (resp.status === 401) {
201
- const body = yield* resp.text.pipe(Effect.catchAll(() => Effect.succeed('-')))
202
- return yield* InvalidPullError.make({
234
+ const body = yield* resp.text.pipe(Effect.catch(() => Effect.succeed('-')))
235
+ return yield* new UnknownError({
203
236
  cause: new Error(`Unauthorized (401): Couldn't connect to ElectricSQL: ${body}`),
204
237
  })
205
238
  } else if (resp.status === 400) {
@@ -220,9 +253,7 @@ export const makeSyncBackend =
220
253
  return notYetImplemented(`Electric shape not found`)
221
254
  } else if (resp.status < 200 || resp.status >= 300) {
222
255
  const body = yield* resp.text
223
- return yield* InvalidPullError.make({
224
- cause: new Error(`Unexpected status code: ${resp.status}: ${body}`),
225
- })
256
+ return yield* new UnknownError({ cause: new Error(`Unexpected status code: ${resp.status}: ${body}`) })
226
257
  }
227
258
 
228
259
  const headers = yield* HttpClientResponse.schemaHeaders(ResponseHeaders)(resp)
@@ -243,7 +274,7 @@ export const makeSyncBackend =
243
274
 
244
275
  // Check for delete/update operations and throw descriptive error
245
276
  const invalidOperations = ReadonlyArray.filterMap(allItems, (item) =>
246
- Schema.is(ResponseItemInvalid)(item) ? Option.some(item.headers.operation) : Option.none(),
277
+ Schema.is(ResponseItemInvalid)(item) === true ? Result.succeed(item.headers.operation) : Result.failVoid,
247
278
  )
248
279
 
249
280
  if (invalidOperations.length > 0) {
@@ -256,7 +287,7 @@ export const makeSyncBackend =
256
287
 
257
288
  const items = allItems.filter(Schema.is(ResponseItemInsert)).map((item) => ({
258
289
  metadata: Option.some({ offset: nextHandle.offset, handle: nextHandle.handle }),
259
- eventEncoded: item.value as LiveStoreEvent.AnyEncodedGlobal,
290
+ eventEncoded: item.value,
260
291
  }))
261
292
 
262
293
  yield* Effect.annotateCurrentSpan({ itemsCount: items.length, nextHandle })
@@ -264,7 +295,7 @@ export const makeSyncBackend =
264
295
  return Option.some([items, Option.some(nextHandle)] as const)
265
296
  }).pipe(
266
297
  Effect.scoped,
267
- Effect.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
298
+ Effect.mapError((cause) => (Schema.is(UnknownError)(cause) === true ? cause : new UnknownError({ cause }))),
268
299
  Effect.withSpan('electric-provider:runPull', { attributes: { handle, live } }),
269
300
  )
270
301
 
@@ -279,9 +310,9 @@ export const makeSyncBackend =
279
310
 
280
311
  yield* SubscriptionRef.set(isConnected, true)
281
312
  }).pipe(
282
- UnexpectedError.mapToUnexpectedError,
313
+ UnknownError.mapToUnknownError,
283
314
  Effect.timeout(pingTimeout),
284
- Effect.catchTag('TimeoutException', () => SubscriptionRef.set(isConnected, false)),
315
+ Effect.catchTag('TimeoutError', () => SubscriptionRef.set(isConnected, false)),
285
316
  Effect.withSpan('electric-provider:ping'),
286
317
  )
287
318
 
@@ -294,62 +325,60 @@ export const makeSyncBackend =
294
325
 
295
326
  // If the pull endpoint has the same origin as the current page, we can assume that we already have a connection
296
327
  // 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)
328
+ const connect: SyncBackend.SyncBackend<SyncMetadata>['connect'] =
329
+ pullEndpointHasSameOrigin === true ? Effect.void : ping.pipe(UnknownError.mapToUnknownError)
300
330
 
301
331
  return SyncBackend.of({
302
332
  connect,
303
333
  pull: (cursor, options) => {
304
334
  let hasEmittedAtLeastOnce = false
305
335
 
306
- return Stream.unfoldEffect(cursor.pipe(Option.flatMap((_) => _.metadata)), (metadataOption) =>
336
+ return Stream.unfold(cursor.pipe(Option.flatMap((_) => _.metadata)), (metadataOption) =>
307
337
  Effect.gen(function* () {
308
338
  const result = yield* runPull(metadataOption, { live: options?.live ?? false })
309
- if (Option.isNone(result)) return Option.none()
339
+ if (Option.isNone(result) === true) return undefined
310
340
 
311
341
  const [batch, nextMetadataOption] = result.value
312
342
 
313
343
  // Continue pagination if we have data
314
344
  if (batch.length > 0) {
315
345
  hasEmittedAtLeastOnce = true
316
- return Option.some([{ batch, hasMore: true }, nextMetadataOption])
346
+ return [{ batch, hasMore: true as boolean }, nextMetadataOption] as const
317
347
  }
318
348
 
319
349
  // Make sure we emit at least once even if there's no data or we're live-pulling
320
- if (hasEmittedAtLeastOnce === false || options?.live) {
350
+ if (hasEmittedAtLeastOnce === false || options?.live === true) {
321
351
  hasEmittedAtLeastOnce = true
322
- return Option.some([{ batch, hasMore: false }, nextMetadataOption])
352
+ return [{ batch, hasMore: false as boolean }, nextMetadataOption] as const
323
353
  }
324
354
 
325
355
  // Stop on empty batch (when not live)
326
- return Option.none()
356
+ return undefined
327
357
  }),
328
358
  ).pipe(
329
359
  Stream.map(({ batch, hasMore }) => ({
330
360
  batch,
331
- pageInfo: hasMore ? SyncBackend.pageInfoMoreUnknown : SyncBackend.pageInfoNoMore,
361
+ pageInfo: hasMore === true ? SyncBackend.pageInfoMoreUnknown : SyncBackend.pageInfoNoMore,
332
362
  })),
333
363
  Stream.withSpan('electric-provider:pull'),
334
364
  )
335
365
  },
336
366
 
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')),
367
+ push: Effect.fn('electric-provider:push')(function* (batch) {
368
+ const resp = yield* HttpClientRequest.schemaBodyJson(ApiSchema.PushPayload)(
369
+ HttpClientRequest.post(pushEndpoint),
370
+ ApiSchema.PushPayload.make({ storeId, batch }),
371
+ ).pipe(
372
+ Effect.andThen(httpClient.pipe(HttpClient.filterStatusOk).execute),
373
+ Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
374
+ Effect.scoped,
375
+ Effect.mapError((cause) => UnknownError.make({ cause })),
376
+ )
377
+
378
+ if (resp.success === false) {
379
+ return yield* new UnknownError({ cause: new Error('Push failed') })
380
+ }
381
+ }),
353
382
  ping,
354
383
  isConnected,
355
384
  metadata: {
@@ -1,5 +1,6 @@
1
1
  import { shouldNeverHappen } from '@livestore/utils'
2
- import { Hash, Schema } from '@livestore/utils/effect'
2
+ import { Hash, Result, Schema } from '@livestore/utils/effect'
3
+
3
4
  import * as ApiSchema from './api-schema.ts'
4
5
 
5
6
  /**
@@ -39,13 +40,13 @@ export const makeElectricUrl = ({
39
40
  */
40
41
  needsInit: boolean
41
42
  /** Sync payload provided by the client */
42
- payload: Schema.JsonValue | undefined
43
+ payload: Schema.Json | undefined
43
44
  } => {
44
45
  const endpointUrl = `${electricHost}/v1/shape`
45
46
  const UrlParamsSchema = Schema.Struct({ args: ApiSchema.ArgsSchema })
46
- const argsResult = Schema.decodeUnknownEither(UrlParamsSchema)(Object.fromEntries(providedSearchParams.entries()))
47
+ const argsResult = Schema.decodeUnknownResult(UrlParamsSchema)(Object.fromEntries(providedSearchParams.entries()))
47
48
 
48
- if (argsResult._tag === 'Left') {
49
+ if (Result.isFailure(argsResult) === true) {
49
50
  return shouldNeverHappen(
50
51
  'Invalid search params provided to makeElectricUrl',
51
52
  providedSearchParams,
@@ -53,7 +54,7 @@ export const makeElectricUrl = ({
53
54
  )
54
55
  }
55
56
 
56
- const args = argsResult.right.args
57
+ const args = argsResult.success.args
57
58
  const tableName = toTableName(args.storeId)
58
59
  // TODO refactor with Effect URLSearchParams schema
59
60
  // https://electric-sql.com/openapi.html
@@ -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