@livestore/sync-electric 0.3.0-dev.23 → 0.3.0-dev.25

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,4 +1,4 @@
1
- import type { IsOfflineError, SyncBackend } from '@livestore/common'
1
+ import type { IsOfflineError, SyncBackend, SyncBackendConstructor } from '@livestore/common'
2
2
  import { InvalidPullError, InvalidPushError } from '@livestore/common'
3
3
  import type { EventId } from '@livestore/common/schema'
4
4
  import { MutationEvent } from '@livestore/common/schema'
@@ -67,7 +67,7 @@ const MutationEventGlobalFromStringRecord = Schema.Struct({
67
67
  mutation: Schema.String,
68
68
  args: Schema.parseJson(Schema.Any),
69
69
  clientId: Schema.String,
70
- sessionId: Schema.optional(Schema.String),
70
+ sessionId: Schema.String,
71
71
  }).pipe(
72
72
  Schema.transform(MutationEvent.AnyEncodedGlobal, {
73
73
  decode: (_) => _,
@@ -101,11 +101,16 @@ export const syncBackend = {} as any
101
101
 
102
102
  export const syncBackendOptions = <TOptions extends SyncBackendOptions>(options: TOptions) => options
103
103
 
104
+ /**
105
+ * This function should be called in a trusted environment (e.g. a proxy server) as it
106
+ * requires access to senstive information (e.g. `apiSecret` / `sourceSecret`).
107
+ */
104
108
  export const makeElectricUrl = ({
105
109
  electricHost,
106
110
  searchParams: providedSearchParams,
107
111
  sourceId,
108
112
  sourceSecret,
113
+ apiSecret,
109
114
  }: {
110
115
  electricHost: string
111
116
  /**
@@ -119,6 +124,8 @@ export const makeElectricUrl = ({
119
124
  sourceId?: string
120
125
  /** Needed for Electric Cloud */
121
126
  sourceSecret?: string
127
+ /** For self-hosted ElectricSQL */
128
+ apiSecret?: string
122
129
  }) => {
123
130
  const endpointUrl = `${electricHost}/v1/shape`
124
131
  const argsResult = Schema.decodeUnknownEither(Schema.Struct({ args: Schema.parseJson(ApiSchema.PullPayload) }))(
@@ -126,7 +133,11 @@ export const makeElectricUrl = ({
126
133
  )
127
134
 
128
135
  if (argsResult._tag === 'Left') {
129
- return shouldNeverHappen('Invalid search params provided to makeElectricUrl', providedSearchParams)
136
+ return shouldNeverHappen(
137
+ 'Invalid search params provided to makeElectricUrl',
138
+ providedSearchParams,
139
+ Object.fromEntries(providedSearchParams.entries()),
140
+ )
130
141
  }
131
142
 
132
143
  const args = argsResult.right.args
@@ -139,6 +150,9 @@ export const makeElectricUrl = ({
139
150
  if (sourceSecret !== undefined) {
140
151
  searchParams.set('source_secret', sourceSecret)
141
152
  }
153
+ if (apiSecret !== undefined) {
154
+ searchParams.set('api_secret', apiSecret)
155
+ }
142
156
  if (args.handle._tag === 'None') {
143
157
  searchParams.set('offset', '-1')
144
158
  } else {
@@ -153,7 +167,6 @@ export const makeElectricUrl = ({
153
167
  }
154
168
 
155
169
  export interface SyncBackendOptions {
156
- storeId: string
157
170
  /**
158
171
  * The endpoint to pull/push events. Pull is a `GET` request, push is a `POST` request.
159
172
  * Usually this endpoint is part of your API layer to proxy requests to the Electric server
@@ -182,153 +195,167 @@ type SyncMetadata = {
182
195
  handle: string
183
196
  }
184
197
 
185
- export const makeSyncBackend = ({
186
- storeId,
187
- endpoint,
188
- }: SyncBackendOptions): Effect.Effect<SyncBackend<SyncMetadata>, never, Scope.Scope> =>
189
- Effect.gen(function* () {
190
- const isConnected = yield* SubscriptionRef.make(true)
191
- const pullEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.pull
192
- const pushEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.push
193
-
194
- // TODO check whether we still need this
195
- const pendingPushDeferredMap = new Map<EventId.GlobalEventId, Deferred.Deferred<SyncMetadata>>()
196
-
197
- const pull = (
198
- handle: Option.Option<SyncMetadata>,
199
- ): Effect.Effect<
200
- Option.Option<
201
- readonly [
202
- Chunk.Chunk<{ metadata: Option.Option<SyncMetadata>; mutationEventEncoded: MutationEvent.AnyEncodedGlobal }>,
203
- Option.Option<SyncMetadata>,
204
- ]
205
- >,
206
- InvalidPullError | IsOfflineError,
207
- HttpClient.HttpClient
208
- > =>
209
- Effect.gen(function* () {
210
- const argsJson = yield* Schema.encode(Schema.parseJson(ApiSchema.PullPayload))(
211
- ApiSchema.PullPayload.make({ storeId, handle }),
212
- )
213
- const url = `${pullEndpoint}?args=${argsJson}`
214
-
215
- const resp = yield* HttpClient.get(url)
216
-
217
- const headers = yield* HttpClientResponse.schemaHeaders(ResponseHeaders)(resp)
218
- const nextHandle = {
219
- offset: headers['electric-offset'],
220
- handle: headers['electric-handle'],
221
- }
222
-
223
- // TODO handle case where Electric shape is not found for a given handle
224
- // https://electric-sql.com/openapi.html#/paths/~1v1~1shape/get
225
- // {
226
- // "message": "The shape associated with this shape_handle and offset was not found. Resync to fetch the latest shape",
227
- // "shape_handle": "2494_84241",
228
- // "offset": "-1"
229
- // }
230
- if (resp.status === 409) {
231
- // TODO: implementation plan:
232
- // start pulling events from scratch with the new handle and ignore the "old events"
233
- // until we found a new event, then, continue with the new handle
234
- return notYetImplemented(`Electric shape not found for handle ${nextHandle.handle}`)
235
- }
236
-
237
- // Electric completes the long-poll request after ~20 seconds with a 204 status
238
- // In this case we just retry where we left off
239
- if (resp.status === 204) {
240
- return Option.some([Chunk.empty(), Option.some(nextHandle)] as const)
241
- }
242
-
243
- const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem), {
244
- onExcessProperty: 'preserve',
245
- })(resp)
246
-
247
- const items = body
248
- .filter((item) => item.value !== undefined && (item.headers as any).operation === 'insert')
249
- .map((item) => ({
250
- metadata: Option.some({ offset: nextHandle.offset!, handle: nextHandle.handle }),
251
- mutationEventEncoded: item.value! as MutationEvent.AnyEncodedGlobal,
252
- }))
253
-
254
- // // TODO implement proper `remaining` handling
255
- // remaining: 0,
198
+ export const makeSyncBackend =
199
+ ({ endpoint }: SyncBackendOptions): SyncBackendConstructor<SyncMetadata> =>
200
+ ({ storeId, payload }) =>
201
+ Effect.gen(function* () {
202
+ const isConnected = yield* SubscriptionRef.make(true)
203
+ const pullEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.pull
204
+ const pushEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.push
205
+
206
+ // TODO check whether we still need this
207
+ const pendingPushDeferredMap = new Map<EventId.GlobalEventId, Deferred.Deferred<SyncMetadata>>()
208
+
209
+ const pull = (
210
+ handle: Option.Option<SyncMetadata>,
211
+ ): Effect.Effect<
212
+ Option.Option<
213
+ readonly [
214
+ Chunk.Chunk<{
215
+ metadata: Option.Option<SyncMetadata>
216
+ mutationEventEncoded: MutationEvent.AnyEncodedGlobal
217
+ }>,
218
+ Option.Option<SyncMetadata>,
219
+ ]
220
+ >,
221
+ InvalidPullError | IsOfflineError,
222
+ HttpClient.HttpClient
223
+ > =>
224
+ Effect.gen(function* () {
225
+ const argsJson = yield* Schema.encode(Schema.parseJson(ApiSchema.PullPayload))(
226
+ ApiSchema.PullPayload.make({ storeId, handle }),
227
+ )
228
+ const url = `${pullEndpoint}?args=${argsJson}`
256
229
 
257
- // if (listenForNew === false && items.length === 0) {
258
- // return Option.none()
259
- // }
230
+ const resp = yield* HttpClient.get(url)
260
231
 
261
- for (const item of items) {
262
- const deferred = pendingPushDeferredMap.get(item.mutationEventEncoded.id)
263
- if (deferred !== undefined) {
264
- yield* Deferred.succeed(deferred, Option.getOrThrow(item.metadata))
232
+ if (resp.status === 401) {
233
+ return yield* InvalidPullError.make({
234
+ message: `Unauthorized (401): Couldn't connect to ElectricSQL`,
235
+ })
265
236
  }
266
- }
267
237
 
268
- return Option.some([Chunk.fromIterable(items), Option.some(nextHandle)] as const)
269
- }).pipe(
270
- Effect.scoped,
271
- Effect.mapError((cause) => InvalidPullError.make({ message: cause.toString() })),
272
- )
273
-
274
- return {
275
- pull: (args) =>
276
- Stream.unfoldChunkEffect(
277
- args.pipe(
278
- Option.map((_) => _.metadata),
279
- Option.flatten,
280
- ),
281
- (metadataOption) => pull(metadataOption),
282
- ).pipe(
283
- Stream.chunks,
284
- Stream.map((chunk) => ({ batch: [...chunk], remaining: 0 })),
285
- ),
238
+ const headers = yield* HttpClientResponse.schemaHeaders(ResponseHeaders)(resp)
239
+ const nextHandle = {
240
+ offset: headers['electric-offset'],
241
+ handle: headers['electric-handle'],
242
+ }
286
243
 
287
- push: (batch) =>
288
- Effect.gen(function* () {
289
- const deferreds: Deferred.Deferred<SyncMetadata>[] = []
290
- for (const mutationEventEncoded of batch) {
291
- const deferred = yield* Deferred.make<SyncMetadata>()
292
- pendingPushDeferredMap.set(mutationEventEncoded.id, deferred)
293
- deferreds.push(deferred)
244
+ // TODO handle case where Electric shape is not found for a given handle
245
+ // https://electric-sql.com/openapi.html#/paths/~1v1~1shape/get
246
+ // {
247
+ // "message": "The shape associated with this shape_handle and offset was not found. Resync to fetch the latest shape",
248
+ // "shape_handle": "2494_84241",
249
+ // "offset": "-1"
250
+ // }
251
+ if (resp.status === 409) {
252
+ // TODO: implementation plan:
253
+ // start pulling events from scratch with the new handle and ignore the "old events"
254
+ // until we found a new event, then, continue with the new handle
255
+ return notYetImplemented(`Electric shape not found for handle ${nextHandle.handle}`)
294
256
  }
295
257
 
296
- const resp = yield* HttpClientRequest.schemaBodyJson(ApiSchema.PushPayload)(
297
- HttpClientRequest.post(pushEndpoint),
298
- ApiSchema.PushPayload.make({ storeId, batch }),
299
- ).pipe(
300
- Effect.andThen(HttpClient.execute),
301
- Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
302
- Effect.scoped,
303
- Effect.mapError((cause) =>
304
- InvalidPushError.make({ reason: { _tag: 'Unexpected', message: cause.toString() } }),
305
- ),
306
- )
258
+ // Electric completes the long-poll request after ~20 seconds with a 204 status
259
+ // In this case we just retry where we left off
260
+ if (resp.status === 204) {
261
+ return Option.some([Chunk.empty(), Option.some(nextHandle)] as const)
262
+ }
307
263
 
308
- if (!resp.success) {
309
- yield* InvalidPushError.make({ reason: { _tag: 'Unexpected', message: 'Push failed' } })
264
+ const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem), {
265
+ onExcessProperty: 'preserve',
266
+ })(resp)
267
+
268
+ const items = body
269
+ .filter((item) => item.value !== undefined && (item.headers as any).operation === 'insert')
270
+ .map((item) => ({
271
+ metadata: Option.some({ offset: nextHandle.offset!, handle: nextHandle.handle }),
272
+ mutationEventEncoded: item.value! as MutationEvent.AnyEncodedGlobal,
273
+ }))
274
+
275
+ // // TODO implement proper `remaining` handling
276
+ // remaining: 0,
277
+
278
+ // if (listenForNew === false && items.length === 0) {
279
+ // return Option.none()
280
+ // }
281
+
282
+ for (const item of items) {
283
+ const deferred = pendingPushDeferredMap.get(item.mutationEventEncoded.id)
284
+ if (deferred !== undefined) {
285
+ yield* Deferred.succeed(deferred, Option.getOrThrow(item.metadata))
286
+ }
310
287
  }
311
288
 
312
- const metadata = yield* Effect.all(deferreds, { concurrency: 'unbounded' }).pipe(
313
- Effect.map((_) => _.map(Option.some)),
314
- )
289
+ return Option.some([Chunk.fromIterable(items), Option.some(nextHandle)] as const)
290
+ }).pipe(
291
+ Effect.scoped,
292
+ Effect.mapError((cause) => InvalidPullError.make({ message: cause.toString() })),
293
+ )
315
294
 
316
- for (const mutationEventEncoded of batch) {
317
- pendingPushDeferredMap.delete(mutationEventEncoded.id)
318
- }
295
+ return {
296
+ pull: (args) =>
297
+ Stream.unfoldChunkEffect(
298
+ args.pipe(
299
+ Option.map((_) => _.metadata),
300
+ Option.flatten,
301
+ ),
302
+ (metadataOption) => pull(metadataOption),
303
+ ).pipe(
304
+ Stream.chunks,
305
+ Stream.map((chunk) => ({ batch: [...chunk], remaining: 0 })),
306
+ ),
319
307
 
320
- return { metadata }
321
- }),
322
- isConnected,
323
- } satisfies SyncBackend<SyncMetadata>
324
- })
308
+ push: (batch) =>
309
+ Effect.gen(function* () {
310
+ const deferreds: Deferred.Deferred<SyncMetadata>[] = []
311
+ for (const mutationEventEncoded of batch) {
312
+ const deferred = yield* Deferred.make<SyncMetadata>()
313
+ pendingPushDeferredMap.set(mutationEventEncoded.id, deferred)
314
+ deferreds.push(deferred)
315
+ }
316
+
317
+ const resp = yield* HttpClientRequest.schemaBodyJson(ApiSchema.PushPayload)(
318
+ HttpClientRequest.post(pushEndpoint),
319
+ ApiSchema.PushPayload.make({ storeId, batch }),
320
+ ).pipe(
321
+ Effect.andThen(HttpClient.execute),
322
+ Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
323
+ Effect.scoped,
324
+ Effect.mapError((cause) =>
325
+ InvalidPushError.make({ reason: { _tag: 'Unexpected', message: cause.toString() } }),
326
+ ),
327
+ )
328
+
329
+ if (!resp.success) {
330
+ yield* InvalidPushError.make({ reason: { _tag: 'Unexpected', message: 'Push failed' } })
331
+ }
332
+
333
+ const metadata = yield* Effect.all(deferreds, { concurrency: 'unbounded' }).pipe(
334
+ Effect.map((_) => _.map(Option.some)),
335
+ )
336
+
337
+ for (const mutationEventEncoded of batch) {
338
+ pendingPushDeferredMap.delete(mutationEventEncoded.id)
339
+ }
340
+
341
+ return { metadata }
342
+ }),
343
+ isConnected,
344
+ metadata: {
345
+ name: '@livestore/sync-electric',
346
+ description: 'LiveStore sync backend implementation using ElectricSQL',
347
+ protocol: 'http',
348
+ endpoint,
349
+ },
350
+ } satisfies SyncBackend<SyncMetadata>
351
+ })
325
352
 
326
353
  /**
327
354
  * Needs to be bumped when the storage format changes (e.g. mutationLogTable schema changes)
328
355
  *
329
356
  * Changing this version number will lead to a "soft reset".
330
357
  */
331
- export const PERSISTENCE_FORMAT_VERSION = 3
358
+ export const PERSISTENCE_FORMAT_VERSION = 4
332
359
 
333
360
  export const toTableName = (storeId: string) => {
334
361
  const escapedStoreId = storeId.replaceAll(/[^a-zA-Z0-9_]/g, '_')
package/tmp/pack.tgz CHANGED
Binary file