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

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,14 +1,20 @@
1
- import type { IsOfflineError, SyncBackend, SyncBackendConstructor } from '@livestore/common'
2
- import { InvalidPullError, InvalidPushError, UnexpectedError } from '@livestore/common'
1
+ import {
2
+ InvalidPullError,
3
+ InvalidPushError,
4
+ type IsOfflineError,
5
+ SyncBackend,
6
+ UnexpectedError,
7
+ } from '@livestore/common'
3
8
  import { LiveStoreEvent } from '@livestore/common/schema'
4
- import { notYetImplemented, shouldNeverHappen } from '@livestore/utils'
9
+ import { notYetImplemented } from '@livestore/utils'
5
10
  import {
6
- Chunk,
11
+ type Duration,
7
12
  Effect,
8
13
  HttpClient,
9
14
  HttpClientRequest,
10
15
  HttpClientResponse,
11
16
  Option,
17
+ Schedule,
12
18
  Schema,
13
19
  Stream,
14
20
  SubscriptionRef,
@@ -17,6 +23,7 @@ import {
17
23
  import * as ApiSchema from './api-schema.ts'
18
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:
@@ -98,73 +105,6 @@ export const syncBackend = {} as any
98
105
 
99
106
  export const syncBackendOptions = <TOptions extends SyncBackendOptions>(options: TOptions) => options
100
107
 
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
108
  export interface SyncBackendOptions {
169
109
  /**
170
110
  * The endpoint to pull/push events. Pull is a `GET` request, push is a `POST` request.
@@ -179,7 +119,25 @@ export interface SyncBackendOptions {
179
119
  | {
180
120
  push: string
181
121
  pull: string
122
+ ping: string
182
123
  }
124
+
125
+ ping?: {
126
+ /**
127
+ * @default true
128
+ */
129
+ enabled?: boolean
130
+ /**
131
+ * How long to wait for a ping response before timing out
132
+ * @default 10 seconds
133
+ */
134
+ requestTimeout?: Duration.DurationInput
135
+ /**
136
+ * How often to send ping requests
137
+ * @default 10 seconds
138
+ */
139
+ requestInterval?: Duration.DurationInput
140
+ }
183
141
  }
184
142
 
185
143
  export const SyncMetadata = Schema.Struct({
@@ -195,41 +153,50 @@ type SyncMetadata = {
195
153
  }
196
154
 
197
155
  export const makeSyncBackend =
198
- ({ endpoint }: SyncBackendOptions): SyncBackendConstructor<SyncMetadata> =>
156
+ ({ endpoint, ...options }: SyncBackendOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
199
157
  ({ storeId, payload }) =>
200
158
  Effect.gen(function* () {
201
- const isConnected = yield* SubscriptionRef.make(true)
159
+ const isConnected = yield* SubscriptionRef.make(false)
202
160
  const pullEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.pull
203
161
  const pushEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.push
162
+ const pingEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.ping
163
+
164
+ const httpClient = yield* HttpClient.HttpClient
204
165
 
205
- const pull = (
166
+ const runPull = (
206
167
  handle: Option.Option<SyncMetadata>,
168
+ { live }: { live: boolean },
207
169
  ): Effect.Effect<
208
170
  Option.Option<
209
171
  readonly [
210
- Chunk.Chunk<{
172
+ /** The batch of events */
173
+ ReadonlyArray<{
211
174
  metadata: Option.Option<SyncMetadata>
212
175
  eventEncoded: LiveStoreEvent.AnyEncodedGlobal
213
176
  }>,
177
+ /** The next handle to use for the next pull */
214
178
  Option.Option<SyncMetadata>,
215
179
  ]
216
180
  >,
217
- InvalidPullError | IsOfflineError,
218
- HttpClient.HttpClient
181
+ InvalidPullError | IsOfflineError
219
182
  > =>
220
183
  Effect.gen(function* () {
221
- const argsJson = yield* Schema.encode(Schema.parseJson(ApiSchema.PullPayload))(
222
- ApiSchema.PullPayload.make({ storeId, handle, payload }),
184
+ const argsJson = yield* Schema.encode(ApiSchema.ArgsSchema)(
185
+ ApiSchema.PullPayload.make({ storeId, handle, payload, live }),
223
186
  )
224
187
  const url = `${pullEndpoint}?args=${argsJson}`
225
188
 
226
- const resp = yield* HttpClient.get(url)
189
+ const resp = yield* httpClient.get(url)
227
190
 
228
191
  if (resp.status === 401) {
229
192
  const body = yield* resp.text.pipe(Effect.catchAll(() => Effect.succeed('-')))
230
193
  return yield* InvalidPullError.make({
231
- message: `Unauthorized (401): Couldn't connect to ElectricSQL: ${body}`,
194
+ cause: new Error(`Unauthorized (401): Couldn't connect to ElectricSQL: ${body}`),
232
195
  })
196
+ } else if (resp.status === 400) {
197
+ // Electric returns 400 when table doesn't exist
198
+ // Return empty result for non-existent tables
199
+ return Option.some([[], Option.none()] as const)
233
200
  } else if (resp.status === 409) {
234
201
  // https://electric-sql.com/openapi.html#/paths/~1v1~1shape/get
235
202
  // {
@@ -243,8 +210,9 @@ export const makeSyncBackend =
243
210
  // until we found a new event, then, continue with the new handle
244
211
  return notYetImplemented(`Electric shape not found`)
245
212
  } else if (resp.status < 200 || resp.status >= 300) {
213
+ const body = yield* resp.text
246
214
  return yield* InvalidPullError.make({
247
- message: `Unexpected status code: ${resp.status}`,
215
+ cause: new Error(`Unexpected status code: ${resp.status}: ${body}`),
248
216
  })
249
217
  }
250
218
 
@@ -257,7 +225,7 @@ export const makeSyncBackend =
257
225
  // Electric completes the long-poll request after ~20 seconds with a 204 status
258
226
  // In this case we just retry where we left off
259
227
  if (resp.status === 204) {
260
- return Option.some([Chunk.empty(), Option.some(nextHandle)] as const)
228
+ return Option.some([[], Option.some(nextHandle)] as const)
261
229
  }
262
230
 
263
231
  const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem), {
@@ -267,46 +235,84 @@ export const makeSyncBackend =
267
235
  const items = body
268
236
  .filter((item) => item.value !== undefined && (item.headers as any).operation === 'insert')
269
237
  .map((item) => ({
270
- metadata: Option.some({ offset: nextHandle.offset!, handle: nextHandle.handle }),
238
+ metadata: Option.some({ offset: nextHandle.offset, handle: nextHandle.handle }),
271
239
  eventEncoded: item.value! as LiveStoreEvent.AnyEncodedGlobal,
272
240
  }))
273
241
 
274
- // // TODO implement proper `remaining` handling
275
- // remaining: 0,
242
+ yield* Effect.annotateCurrentSpan({ itemsCount: items.length, nextHandle })
276
243
 
277
- // if (listenForNew === false && items.length === 0) {
278
- // return Option.none()
279
- // }
280
-
281
- return Option.some([Chunk.fromIterable(items), Option.some(nextHandle)] as const)
244
+ return Option.some([items, Option.some(nextHandle)] as const)
282
245
  }).pipe(
283
246
  Effect.scoped,
284
- Effect.mapError((cause) =>
285
- cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ message: cause.toString() }),
286
- ),
247
+ Effect.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
248
+ Effect.withSpan('electric-provider:runPull', { attributes: { handle, live } }),
287
249
  )
288
250
 
289
251
  const pullEndpointHasSameOrigin =
290
252
  pullEndpoint.startsWith('/') ||
291
253
  (globalThis.location !== undefined && globalThis.location.origin === new URL(pullEndpoint).origin)
292
254
 
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),
255
+ const pingTimeout = options.ping?.requestTimeout ?? 10_000
256
+
257
+ const ping: SyncBackend.SyncBackend<SyncMetadata>['ping'] = Effect.gen(function* () {
258
+ yield* httpClient.pipe(HttpClient.filterStatusOk).head(pingEndpoint)
259
+
260
+ yield* SubscriptionRef.set(isConnected, true)
261
+ }).pipe(
262
+ UnexpectedError.mapToUnexpectedError,
263
+ Effect.timeout(pingTimeout),
264
+ Effect.catchTag('TimeoutException', () => SubscriptionRef.set(isConnected, false)),
265
+ Effect.withSpan('electric-provider:ping'),
266
+ )
267
+
268
+ const pingInterval = options.ping?.requestInterval ?? 10_000
269
+
270
+ if (options.ping?.enabled !== false) {
271
+ // Automatically ping the server to keep the connection alive
272
+ yield* ping.pipe(Effect.repeat(Schedule.spaced(pingInterval)), Effect.tapCauseLogPretty, Effect.forkScoped)
273
+ }
274
+
275
+ // If the pull endpoint has the same origin as the current page, we can assume that we already have a connection
276
+ // otherwise we send a HEAD request to speed up the connection process
277
+ const connect: SyncBackend.SyncBackend<SyncMetadata>['connect'] = pullEndpointHasSameOrigin
278
+ ? Effect.void
279
+ : ping.pipe(UnexpectedError.mapToUnexpectedError)
280
+
281
+ return SyncBackend.of({
282
+ connect,
283
+ pull: (cursor, options) => {
284
+ let hasEmittedAtLeastOnce = false
285
+
286
+ return Stream.unfoldEffect(cursor.pipe(Option.flatMap((_) => _.metadata)), (metadataOption) =>
287
+ Effect.gen(function* () {
288
+ const result = yield* runPull(metadataOption, { live: options?.live ?? false })
289
+ if (Option.isNone(result)) return Option.none()
290
+
291
+ const [batch, nextMetadataOption] = result.value
292
+
293
+ // Continue pagination if we have data
294
+ if (batch.length > 0) {
295
+ hasEmittedAtLeastOnce = true
296
+ return Option.some([{ batch, hasMore: true }, nextMetadataOption])
297
+ }
298
+
299
+ // Make sure we emit at least once even if there's no data or we're live-pulling
300
+ if (hasEmittedAtLeastOnce === false || options?.live) {
301
+ hasEmittedAtLeastOnce = true
302
+ return Option.some([{ batch, hasMore: false }, nextMetadataOption])
303
+ }
304
+
305
+ // Stop on empty batch (when not live)
306
+ return Option.none()
307
+ }),
306
308
  ).pipe(
307
- Stream.chunks,
308
- Stream.map((chunk) => ({ batch: [...chunk], remaining: 0 })),
309
- ),
309
+ Stream.map(({ batch, hasMore }) => ({
310
+ batch,
311
+ pageInfo: hasMore ? SyncBackend.pageInfoMoreUnknown : SyncBackend.pageInfoNoMore,
312
+ })),
313
+ Stream.withSpan('electric-provider:pull'),
314
+ )
315
+ },
310
316
 
311
317
  push: (batch) =>
312
318
  Effect.gen(function* () {
@@ -314,18 +320,17 @@ export const makeSyncBackend =
314
320
  HttpClientRequest.post(pushEndpoint),
315
321
  ApiSchema.PushPayload.make({ storeId, batch }),
316
322
  ).pipe(
317
- Effect.andThen(HttpClient.execute),
323
+ Effect.andThen(httpClient.pipe(HttpClient.filterStatusOk).execute),
318
324
  Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
319
325
  Effect.scoped,
320
- Effect.mapError((cause) =>
321
- InvalidPushError.make({ reason: { _tag: 'Unexpected', message: cause.toString() } }),
322
- ),
326
+ Effect.mapError((cause) => InvalidPushError.make({ cause: UnexpectedError.make({ cause }) })),
323
327
  )
324
328
 
325
329
  if (!resp.success) {
326
- yield* InvalidPushError.make({ reason: { _tag: 'Unexpected', message: 'Push failed' } })
330
+ return yield* InvalidPushError.make({ cause: new UnexpectedError({ cause: new Error('Push failed') }) })
327
331
  }
328
- }),
332
+ }).pipe(Effect.withSpan('electric-provider:push')),
333
+ ping,
329
334
  isConnected,
330
335
  metadata: {
331
336
  name: '@livestore/sync-electric',
@@ -333,17 +338,11 @@ export const makeSyncBackend =
333
338
  protocol: 'http',
334
339
  endpoint,
335
340
  },
336
- } satisfies SyncBackend<SyncMetadata>
341
+ supports: {
342
+ // Given Electric is heavily optimized for immutable caching, we can't know the remaining count
343
+ // until we've reached the end of the stream
344
+ pullPageInfoKnown: false,
345
+ pullLive: true,
346
+ },
347
+ })
337
348
  })
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,110 @@
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
+ * Needs to be bumped when the storage format changes (e.g. eventlogTable schema changes)
107
+ *
108
+ * Changing this version number will lead to a "soft reset".
109
+ */
110
+ export const PERSISTENCE_FORMAT_VERSION = 6