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

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 @@
1
+ {"version":3,"file":"make-electric-url.js","sourceRoot":"","sources":["../src/make-electric-url.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AACpD,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAA;AACtD,OAAO,KAAK,SAAS,MAAM,iBAAiB,CAAA;AAE5C;;;GAGG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,EAC9B,YAAY,EACZ,YAAY,EAAE,oBAAoB,EAClC,QAAQ,EACR,YAAY,EACZ,SAAS,GAgBV,EAaC,EAAE;IACF,MAAM,WAAW,GAAG,GAAG,YAAY,WAAW,CAAA;IAC9C,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,SAAS,CAAC,UAAU,EAAE,CAAC,CAAA;IACrE,MAAM,UAAU,GAAG,MAAM,CAAC,mBAAmB,CAAC,eAAe,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,oBAAoB,CAAC,OAAO,EAAE,CAAC,CAAC,CAAA;IAElH,IAAI,UAAU,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;QAC/B,OAAO,iBAAiB,CACtB,mDAAmD,EACnD,oBAAoB,EACpB,MAAM,CAAC,WAAW,CAAC,oBAAoB,CAAC,OAAO,EAAE,CAAC,CACnD,CAAA;IACH,CAAC;IAED,MAAM,IAAI,GAAG,UAAU,CAAC,KAAK,CAAC,IAAI,CAAA;IAClC,MAAM,SAAS,GAAG,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;IAC3C,mDAAmD;IACnD,wCAAwC;IACxC,MAAM,YAAY,GAAG,IAAI,eAAe,EAAE,CAAA;IAC1C,kEAAkE;IAClE,qFAAqF;IACrF,YAAY,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,SAAS,GAAG,CAAC,CAAA;IAC3C,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;QAC3B,YAAY,CAAC,GAAG,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAA;IACzC,CAAC;IACD,IAAI,YAAY,KAAK,SAAS,EAAE,CAAC;QAC/B,YAAY,CAAC,GAAG,CAAC,eAAe,EAAE,YAAY,CAAC,CAAA;IACjD,CAAC;IACD,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;QAC5B,YAAY,CAAC,GAAG,CAAC,YAAY,EAAE,SAAS,CAAC,CAAA;IAC3C,CAAC;IACD,IAAI,IAAI,CAAC,MAAM,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;QAChC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAA;IAClC,CAAC;SAAM,CAAC;QACN,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAA;QACpD,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAA;QACpD,YAAY,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,CAAA;IACxD,CAAC;IAED,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAA;IAE5B,MAAM,GAAG,GAAG,GAAG,WAAW,IAAI,YAAY,CAAC,QAAQ,EAAE,EAAE,CAAA;IAEvD,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,SAAS,EAAE,IAAI,CAAC,MAAM,CAAC,IAAI,KAAK,MAAM,EAAE,OAAO,EAAE,CAAA;AACxF,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,OAAe,EAAE,EAAE;IAC7C,MAAM,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC,gBAAgB,EAAE,GAAG,CAAC,CAAA;IAChE,MAAM,SAAS,GAAG,YAAY,0BAA0B,IAAI,cAAc,EAAE,CAAA;IAE5E,IAAI,SAAS,CAAC,MAAM,GAAG,EAAE,EAAE,CAAC;QAC1B,MAAM,aAAa,GAAG,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;QAE1C,OAAO,CAAC,IAAI,CACV,4BAA4B,SAAS,wFAAwF,aAAa,IAAI,CAC/I,CAAA;QAED,OAAO,YAAY,0BAA0B,SAAS,aAAa,EAAE,CAAA;IACvE,CAAC;IAED,OAAO,SAAS,CAAA;AAClB,CAAC,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG,CAAC,CAAA"}
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.20",
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.20",
11
+ "@livestore/utils": "0.4.0-dev.20"
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,53 @@ 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
 
197
153
  export const makeSyncBackend =
198
- ({ endpoint }: SyncBackendOptions): SyncBackendConstructor<SyncMetadata> =>
154
+ ({ endpoint, ...options }: SyncBackendOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
199
155
  ({ storeId, payload }) =>
200
156
  Effect.gen(function* () {
201
- const isConnected = yield* SubscriptionRef.make(true)
157
+ const isConnected = yield* SubscriptionRef.make(false)
202
158
  const pullEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.pull
203
159
  const pushEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.push
160
+ const pingEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.ping
161
+
162
+ const httpClient = yield* HttpClient.HttpClient
204
163
 
205
- const pull = (
164
+ const runPull = (
206
165
  handle: Option.Option<SyncMetadata>,
166
+ { live }: { live: boolean },
207
167
  ): Effect.Effect<
208
168
  Option.Option<
209
169
  readonly [
210
- Chunk.Chunk<{
170
+ /** The batch of events */
171
+ ReadonlyArray<{
211
172
  metadata: Option.Option<SyncMetadata>
212
- eventEncoded: LiveStoreEvent.AnyEncodedGlobal
173
+ eventEncoded: LiveStoreEvent.Global.Encoded
213
174
  }>,
175
+ /** The next handle to use for the next pull */
214
176
  Option.Option<SyncMetadata>,
215
177
  ]
216
178
  >,
217
- InvalidPullError | IsOfflineError,
218
- HttpClient.HttpClient
179
+ InvalidPullError | IsOfflineError
219
180
  > =>
220
181
  Effect.gen(function* () {
221
- const argsJson = yield* Schema.encode(Schema.parseJson(ApiSchema.PullPayload))(
222
- ApiSchema.PullPayload.make({ storeId, handle, payload }),
182
+ const argsJson = yield* Schema.encode(ApiSchema.ArgsSchema)(
183
+ ApiSchema.PullPayload.make({ storeId, handle, payload, live }),
223
184
  )
224
185
  const url = `${pullEndpoint}?args=${argsJson}`
225
186
 
226
- const resp = yield* HttpClient.get(url)
187
+ const resp = yield* httpClient.get(url)
227
188
 
228
189
  if (resp.status === 401) {
229
190
  const body = yield* resp.text.pipe(Effect.catchAll(() => Effect.succeed('-')))
230
191
  return yield* InvalidPullError.make({
231
- message: `Unauthorized (401): Couldn't connect to ElectricSQL: ${body}`,
192
+ cause: new Error(`Unauthorized (401): Couldn't connect to ElectricSQL: ${body}`),
232
193
  })
194
+ } else if (resp.status === 400) {
195
+ // Electric returns 400 when table doesn't exist
196
+ // Return empty result for non-existent tables
197
+ return Option.some([[], Option.none()] as const)
233
198
  } else if (resp.status === 409) {
234
199
  // https://electric-sql.com/openapi.html#/paths/~1v1~1shape/get
235
200
  // {
@@ -243,8 +208,9 @@ export const makeSyncBackend =
243
208
  // until we found a new event, then, continue with the new handle
244
209
  return notYetImplemented(`Electric shape not found`)
245
210
  } else if (resp.status < 200 || resp.status >= 300) {
211
+ const body = yield* resp.text
246
212
  return yield* InvalidPullError.make({
247
- message: `Unexpected status code: ${resp.status}`,
213
+ cause: new Error(`Unexpected status code: ${resp.status}: ${body}`),
248
214
  })
249
215
  }
250
216
 
@@ -257,56 +223,105 @@ export const makeSyncBackend =
257
223
  // Electric completes the long-poll request after ~20 seconds with a 204 status
258
224
  // In this case we just retry where we left off
259
225
  if (resp.status === 204) {
260
- return Option.some([Chunk.empty(), Option.some(nextHandle)] as const)
226
+ return Option.some([[], Option.some(nextHandle)] as const)
261
227
  }
262
228
 
263
- const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem), {
229
+ const allItems = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem), {
264
230
  onExcessProperty: 'preserve',
265
231
  })(resp)
266
232
 
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
- }))
233
+ // Check for delete/update operations and throw descriptive error
234
+ const invalidOperations = ReadonlyArray.filterMap(allItems, (item) =>
235
+ Schema.is(ResponseItemInvalid)(item) ? Option.some(item.headers.operation) : Option.none(),
236
+ )
273
237
 
274
- // // TODO implement proper `remaining` handling
275
- // remaining: 0,
238
+ if (invalidOperations.length > 0) {
239
+ const operation = invalidOperations[0]!
240
+ return yield* new InvalidOperationError({
241
+ operation,
242
+ 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.`,
243
+ })
244
+ }
276
245
 
277
- // if (listenForNew === false && items.length === 0) {
278
- // return Option.none()
279
- // }
246
+ const items = allItems.filter(Schema.is(ResponseItemInsert)).map((item) => ({
247
+ metadata: Option.some({ offset: nextHandle.offset, handle: nextHandle.handle }),
248
+ eventEncoded: item.value as LiveStoreEvent.Global.Encoded,
249
+ }))
280
250
 
281
- return Option.some([Chunk.fromIterable(items), Option.some(nextHandle)] as const)
251
+ yield* Effect.annotateCurrentSpan({ itemsCount: items.length, nextHandle })
252
+
253
+ return Option.some([items, Option.some(nextHandle)] as const)
282
254
  }).pipe(
283
255
  Effect.scoped,
284
- Effect.mapError((cause) =>
285
- cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ message: cause.toString() }),
286
- ),
256
+ Effect.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
257
+ Effect.withSpan('electric-provider:runPull', { attributes: { handle, live } }),
287
258
  )
288
259
 
289
260
  const pullEndpointHasSameOrigin =
290
261
  pullEndpoint.startsWith('/') ||
291
262
  (globalThis.location !== undefined && globalThis.location.origin === new URL(pullEndpoint).origin)
292
263
 
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),
264
+ const pingTimeout = options.ping?.requestTimeout ?? 10_000
265
+
266
+ const ping: SyncBackend.SyncBackend<SyncMetadata>['ping'] = Effect.gen(function* () {
267
+ yield* httpClient.pipe(HttpClient.filterStatusOk).head(pingEndpoint)
268
+
269
+ yield* SubscriptionRef.set(isConnected, true)
270
+ }).pipe(
271
+ UnknownError.mapToUnknownError,
272
+ Effect.timeout(pingTimeout),
273
+ Effect.catchTag('TimeoutException', () => SubscriptionRef.set(isConnected, false)),
274
+ Effect.withSpan('electric-provider:ping'),
275
+ )
276
+
277
+ const pingInterval = options.ping?.requestInterval ?? 10_000
278
+
279
+ if (options.ping?.enabled !== false) {
280
+ // Automatically ping the server to keep the connection alive
281
+ yield* ping.pipe(Effect.repeat(Schedule.spaced(pingInterval)), Effect.tapCauseLogPretty, Effect.forkScoped)
282
+ }
283
+
284
+ // If the pull endpoint has the same origin as the current page, we can assume that we already have a connection
285
+ // otherwise we send a HEAD request to speed up the connection process
286
+ const connect: SyncBackend.SyncBackend<SyncMetadata>['connect'] = pullEndpointHasSameOrigin
287
+ ? Effect.void
288
+ : ping.pipe(UnknownError.mapToUnknownError)
289
+
290
+ return SyncBackend.of({
291
+ connect,
292
+ pull: (cursor, options) => {
293
+ let hasEmittedAtLeastOnce = false
294
+
295
+ return Stream.unfoldEffect(cursor.pipe(Option.flatMap((_) => _.metadata)), (metadataOption) =>
296
+ Effect.gen(function* () {
297
+ const result = yield* runPull(metadataOption, { live: options?.live ?? false })
298
+ if (Option.isNone(result)) return Option.none()
299
+
300
+ const [batch, nextMetadataOption] = result.value
301
+
302
+ // Continue pagination if we have data
303
+ if (batch.length > 0) {
304
+ hasEmittedAtLeastOnce = true
305
+ return Option.some([{ batch, hasMore: true }, nextMetadataOption])
306
+ }
307
+
308
+ // Make sure we emit at least once even if there's no data or we're live-pulling
309
+ if (hasEmittedAtLeastOnce === false || options?.live) {
310
+ hasEmittedAtLeastOnce = true
311
+ return Option.some([{ batch, hasMore: false }, nextMetadataOption])
312
+ }
313
+
314
+ // Stop on empty batch (when not live)
315
+ return Option.none()
316
+ }),
306
317
  ).pipe(
307
- Stream.chunks,
308
- Stream.map((chunk) => ({ batch: [...chunk], remaining: 0 })),
309
- ),
318
+ Stream.map(({ batch, hasMore }) => ({
319
+ batch,
320
+ pageInfo: hasMore ? SyncBackend.pageInfoMoreUnknown : SyncBackend.pageInfoNoMore,
321
+ })),
322
+ Stream.withSpan('electric-provider:pull'),
323
+ )
324
+ },
310
325
 
311
326
  push: (batch) =>
312
327
  Effect.gen(function* () {
@@ -314,18 +329,17 @@ export const makeSyncBackend =
314
329
  HttpClientRequest.post(pushEndpoint),
315
330
  ApiSchema.PushPayload.make({ storeId, batch }),
316
331
  ).pipe(
317
- Effect.andThen(HttpClient.execute),
332
+ Effect.andThen(httpClient.pipe(HttpClient.filterStatusOk).execute),
318
333
  Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
319
334
  Effect.scoped,
320
- Effect.mapError((cause) =>
321
- InvalidPushError.make({ reason: { _tag: 'Unexpected', message: cause.toString() } }),
322
- ),
335
+ Effect.mapError((cause) => InvalidPushError.make({ cause: UnknownError.make({ cause }) })),
323
336
  )
324
337
 
325
338
  if (!resp.success) {
326
- yield* InvalidPushError.make({ reason: { _tag: 'Unexpected', message: 'Push failed' } })
339
+ return yield* InvalidPushError.make({ cause: new UnknownError({ cause: new Error('Push failed') }) })
327
340
  }
328
- }),
341
+ }).pipe(Effect.withSpan('electric-provider:push')),
342
+ ping,
329
343
  isConnected,
330
344
  metadata: {
331
345
  name: '@livestore/sync-electric',
@@ -333,17 +347,11 @@ export const makeSyncBackend =
333
347
  protocol: 'http',
334
348
  endpoint,
335
349
  },
336
- } satisfies SyncBackend<SyncMetadata>
350
+ supports: {
351
+ // Given Electric is heavily optimized for immutable caching, we can't know the remaining count
352
+ // until we've reached the end of the stream
353
+ pullPageInfoKnown: false,
354
+ pullLive: true,
355
+ },
356
+ })
337
357
  })
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
- }
@@ -0,0 +1,129 @@
1
+ import { shouldNeverHappen } from '@livestore/utils'
2
+ import { Hash, Schema } from '@livestore/utils/effect'
3
+ import * as ApiSchema from './api-schema.ts'
4
+
5
+ /**
6
+ * This function should be called in a trusted environment (e.g. a proxy server) as it
7
+ * requires access to senstive information (e.g. `apiSecret` / `sourceSecret`).
8
+ */
9
+ export const makeElectricUrl = ({
10
+ electricHost,
11
+ searchParams: providedSearchParams,
12
+ sourceId,
13
+ sourceSecret,
14
+ apiSecret,
15
+ }: {
16
+ electricHost: string
17
+ /**
18
+ * Needed to extract information from the search params which the `@livestore/sync-electric`
19
+ * client implementation automatically adds:
20
+ * - `handle`: the ElectricSQL handle
21
+ * - `storeId`: the Livestore storeId
22
+ */
23
+ searchParams: URLSearchParams
24
+ /** Needed for Electric Cloud */
25
+ sourceId?: string
26
+ /** Needed for Electric Cloud */
27
+ sourceSecret?: string
28
+ /** For self-hosted ElectricSQL */
29
+ apiSecret?: string
30
+ }): {
31
+ /**
32
+ * The URL to the ElectricSQL API endpoint with needed search params.
33
+ */
34
+ url: string
35
+ /** The Livestore storeId */
36
+ storeId: string
37
+ /**
38
+ * Whether the Postgres table needs to be created.
39
+ */
40
+ needsInit: boolean
41
+ /** Sync payload provided by the client */
42
+ payload: Schema.JsonValue | undefined
43
+ } => {
44
+ const endpointUrl = `${electricHost}/v1/shape`
45
+ const UrlParamsSchema = Schema.Struct({ args: ApiSchema.ArgsSchema })
46
+ const argsResult = Schema.decodeUnknownEither(UrlParamsSchema)(Object.fromEntries(providedSearchParams.entries()))
47
+
48
+ if (argsResult._tag === 'Left') {
49
+ return shouldNeverHappen(
50
+ 'Invalid search params provided to makeElectricUrl',
51
+ providedSearchParams,
52
+ Object.fromEntries(providedSearchParams.entries()),
53
+ )
54
+ }
55
+
56
+ const args = argsResult.right.args
57
+ const tableName = toTableName(args.storeId)
58
+ // TODO refactor with Effect URLSearchParams schema
59
+ // https://electric-sql.com/openapi.html
60
+ const searchParams = new URLSearchParams()
61
+ // Electric requires table names with capital letters to be quoted
62
+ // Since our table names include the storeId which may have capitals, we always quote
63
+ searchParams.set('table', `"${tableName}"`)
64
+ if (sourceId !== undefined) {
65
+ searchParams.set('source_id', sourceId)
66
+ }
67
+ if (sourceSecret !== undefined) {
68
+ searchParams.set('source_secret', sourceSecret)
69
+ }
70
+ if (apiSecret !== undefined) {
71
+ searchParams.set('api_secret', apiSecret)
72
+ }
73
+ if (args.handle._tag === 'None') {
74
+ searchParams.set('offset', '-1')
75
+ } else {
76
+ searchParams.set('offset', args.handle.value.offset)
77
+ searchParams.set('handle', args.handle.value.handle)
78
+ searchParams.set('live', args.live ? 'true' : 'false')
79
+ }
80
+
81
+ const payload = args.payload
82
+
83
+ const url = `${endpointUrl}?${searchParams.toString()}`
84
+
85
+ return { url, storeId: args.storeId, needsInit: args.handle._tag === 'None', payload }
86
+ }
87
+
88
+ export const toTableName = (storeId: string) => {
89
+ const escapedStoreId = storeId.replaceAll(/[^a-zA-Z0-9_]/g, '_')
90
+ const tableName = `eventlog_${PERSISTENCE_FORMAT_VERSION}_${escapedStoreId}`
91
+
92
+ if (tableName.length > 63) {
93
+ const hashedStoreId = Hash.string(storeId)
94
+
95
+ console.warn(
96
+ `Table name is too long: "${tableName}". Postgres table names are limited to 63 characters. Using hashed storeId instead: "${hashedStoreId}".`,
97
+ )
98
+
99
+ return `eventlog_${PERSISTENCE_FORMAT_VERSION}_hash_${hashedStoreId}`
100
+ }
101
+
102
+ return tableName
103
+ }
104
+
105
+ /**
106
+ * CRITICAL: Increment this version whenever you modify the Postgres table schema structure.
107
+ *
108
+ * Bump required when:
109
+ * - Adding/removing/renaming columns in the eventlog table (see examples/web-todomvc-sync-electric/src/server/db.ts)
110
+ * - Changing column types or constraints
111
+ * - Modifying primary keys or indexes
112
+ *
113
+ * Bump NOT required when:
114
+ * - Changing query patterns or fetch logic
115
+ * - Adding new tables (as long as existing table schema remains unchanged)
116
+ * - Updating client-side implementation details
117
+ *
118
+ * Impact: Changing this version triggers a "soft reset" - new table names are created
119
+ * and old data becomes inaccessible (but remains in the database).
120
+ *
121
+ * Current schema (PostgreSQL):
122
+ * - seqNum (INTEGER PRIMARY KEY)
123
+ * - parentSeqNum (INTEGER)
124
+ * - name (TEXT NOT NULL)
125
+ * - args (JSONB NOT NULL)
126
+ * - clientId (TEXT NOT NULL)
127
+ * - sessionId (TEXT NOT NULL)
128
+ */
129
+ export const PERSISTENCE_FORMAT_VERSION = 6