@livestore/sync-electric 0.3.0-dev.5 → 0.3.0-dev.50

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,11 +1,9 @@
1
- import type { SyncBackend, SyncBackendOptionsBase } from '@livestore/common'
2
- import { InvalidPullError, InvalidPushError } from '@livestore/common'
3
- import type { EventId } from '@livestore/common/schema'
4
- import { MutationEvent } from '@livestore/common/schema'
5
- import type { Scope } from '@livestore/utils/effect'
1
+ import type { IsOfflineError, SyncBackend, SyncBackendConstructor } from '@livestore/common'
2
+ import { InvalidPullError, InvalidPushError, UnexpectedError } from '@livestore/common'
3
+ import { LiveStoreEvent } from '@livestore/common/schema'
4
+ import { notYetImplemented, shouldNeverHappen } from '@livestore/utils'
6
5
  import {
7
6
  Chunk,
8
- Deferred,
9
7
  Effect,
10
8
  HttpClient,
11
9
  HttpClientRequest,
@@ -16,209 +14,336 @@ import {
16
14
  SubscriptionRef,
17
15
  } from '@livestore/utils/effect'
18
16
 
17
+ import * as ApiSchema from './api-schema.js'
18
+
19
+ export * as ApiSchema from './api-schema.js'
20
+
19
21
  /*
20
22
  Example data:
21
23
 
22
- [{"key":"\"public\".\"events\"/\"1\"","value":{"id":"1","mutation":"test","args_json":"{\"test\":\"test\"}","schema_hash":"1","created_at":"2024-09-07T10:05:31.445Z"},"headers":{"operation":"insert","relation":["public","events"]},"offset":"0_0"}
23
- ,{"key":"\"public\".\"events\"/\"1725703554783\"","value":{"id":"1725703554783","mutation":"test","args_json":"{\"test\":\"test\"}","schema_hash":"1","created_at":"2024-09-07T10:05:54.783Z"},"headers":{"operation":"insert","relation":["public","events"]},"offset":"0_0"}
24
- ,{"headers":{"control":"up-to-date"}}]
24
+ [
25
+ {
26
+ "value": {
27
+ "args": "{\"id\": \"127c3df4-0855-4587-ae75-14463f4a3aa0\", \"text\": \"1\"}",
28
+ "clientId": "S_YOa",
29
+ "id": "0",
30
+ "name": "todoCreated",
31
+ "parentSeqNum": "-1"
32
+ },
33
+ "key": "\"public\".\"events_9069baf0_b3e6_42f7_980f_188416eab3fx3\"/\"0\"",
34
+ "headers": {
35
+ "last": true,
36
+ "relation": [
37
+ "public",
38
+ "events_9069baf0_b3e6_42f7_980f_188416eab3fx3"
39
+ ],
40
+ "operation": "insert",
41
+ "lsn": 27294160,
42
+ "op_position": 0,
43
+ "txids": [
44
+ 753
45
+ ]
46
+ }
47
+ },
48
+ {
49
+ "headers": {
50
+ "control": "up-to-date",
51
+ "global_last_seen_lsn": 27294160
52
+ }
53
+ }
54
+ ]
55
+
25
56
 
26
57
  Also see: https://github.com/electric-sql/electric/blob/main/packages/typescript-client/src/client.ts
27
58
 
28
59
  */
29
60
 
61
+ const LiveStoreEventGlobalFromStringRecord = Schema.Struct({
62
+ seqNum: Schema.NumberFromString,
63
+ parentSeqNum: Schema.NumberFromString,
64
+ name: Schema.String,
65
+ args: Schema.parseJson(Schema.Any),
66
+ clientId: Schema.String,
67
+ sessionId: Schema.String,
68
+ }).pipe(
69
+ Schema.transform(LiveStoreEvent.AnyEncodedGlobal, {
70
+ decode: (_) => _,
71
+ encode: (_) => _,
72
+ }),
73
+ )
74
+
30
75
  const ResponseItem = Schema.Struct({
31
- /** Postgres path (e.g. "public.events/1") */
76
+ /** Postgres path (e.g. `"public"."events_9069baf0_b3e6_42f7_980f_188416eab3fx3"/"0"`) */
32
77
  key: Schema.optional(Schema.String),
33
- value: Schema.optional(MutationEvent.AnyEncodedGlobal),
34
- headers: Schema.Record({ key: Schema.String, value: Schema.Any }),
35
- offset: 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
+ ),
36
88
  })
37
89
 
38
90
  const ResponseHeaders = Schema.Struct({
39
- 'x-electric-shape-id': Schema.String,
40
- // 'x-electric-schema': Schema.parseJson(Schema.Any),
91
+ 'electric-handle': Schema.String,
92
+ // 'electric-schema': Schema.parseJson(Schema.Any),
41
93
  /** e.g. 26799576_0 */
42
- 'x-electric-chunk-last-offset': Schema.String,
94
+ 'electric-offset': Schema.String,
43
95
  })
44
96
 
45
97
  export const syncBackend = {} as any
46
98
 
47
- export const ApiPushEventPayload = Schema.TaggedStruct('sync-electric.PushEvent', {
48
- roomId: Schema.String,
49
- batch: Schema.Array(MutationEvent.AnyEncodedGlobal),
50
- })
51
-
52
- export const ApiInitRoomPayload = Schema.TaggedStruct('sync-electric.InitRoom', {
53
- roomId: Schema.String,
54
- })
55
-
56
- export const ApiPayload = Schema.Union(ApiPushEventPayload, ApiInitRoomPayload)
57
-
58
99
  export const syncBackendOptions = <TOptions extends SyncBackendOptions>(options: TOptions) => options
59
100
 
60
- export interface SyncBackendOptions extends SyncBackendOptionsBase {
61
- type: 'electric'
62
- /**
63
- * The host of the Electric server
64
- *
65
- * @example "https://localhost:3000"
66
- */
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
+ }: {
67
112
  electricHost: string
68
- roomId: string
69
113
  /**
70
- * The POST endpoint to push events to
71
- *
72
- * @example "/api/push-event"
73
- * @example "https://api.myapp.com/push-event"
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
74
118
  */
75
- pushEventEndpoint: string
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 }
76
166
  }
77
167
 
78
- interface LiveStoreGlobalElectric {
79
- syncBackend: SyncBackendOptions
168
+ export interface SyncBackendOptions {
169
+ /**
170
+ * The endpoint to pull/push events. Pull is a `GET` request, push is a `POST` request.
171
+ * Usually this endpoint is part of your API layer to proxy requests to the Electric server
172
+ * e.g. to implement auth, rate limiting, etc.
173
+ *
174
+ * @example "/api/electric"
175
+ * @example { push: "/api/push-event", pull: "/api/pull-event" }
176
+ */
177
+ endpoint:
178
+ | string
179
+ | {
180
+ push: string
181
+ pull: string
182
+ }
80
183
  }
81
184
 
82
- declare global {
83
- interface LiveStoreGlobal extends LiveStoreGlobalElectric {}
84
- }
185
+ export const SyncMetadata = Schema.Struct({
186
+ offset: Schema.String,
187
+ // TODO move this into some kind of "global" sync metadata as it's the same for each event
188
+ handle: Schema.String,
189
+ })
85
190
 
86
191
  type SyncMetadata = {
87
192
  offset: string
88
193
  // TODO move this into some kind of "global" sync metadata as it's the same for each event
89
- shapeId: string
194
+ handle: string
90
195
  }
91
196
 
92
- export const makeSyncBackend = ({
93
- electricHost,
94
- roomId,
95
- pushEventEndpoint,
96
- }: SyncBackendOptions): Effect.Effect<SyncBackend<SyncMetadata>, never, Scope.Scope> =>
97
- Effect.gen(function* () {
98
- const endpointUrl = `${electricHost}/v1/shape/events_${roomId}`
99
-
100
- const isConnected = yield* SubscriptionRef.make(true)
101
-
102
- const initRoom = HttpClientRequest.schemaBodyJson(ApiInitRoomPayload)(
103
- HttpClientRequest.post(pushEventEndpoint),
104
- ApiInitRoomPayload.make({ roomId }),
105
- ).pipe(Effect.andThen(HttpClient.execute))
106
-
107
- // TODO check whether we still need this
108
- const pendingPushDeferredMap = new Map<EventId.GlobalEventId, Deferred.Deferred<SyncMetadata>>()
109
-
110
- const pull = (args: Option.Option<SyncMetadata>) =>
111
- Effect.gen(function* () {
112
- const url =
113
- args._tag === 'None'
114
- ? `${endpointUrl}?offset=-1`
115
- : `${endpointUrl}?offset=${args.value.offset}&shape_id=${args.value.shapeId}&live=true`
116
-
117
- const resp = yield* HttpClient.get(url).pipe(
118
- Effect.tapErrorTag('ResponseError', (error) =>
119
- // TODO handle 409 error when the shapeId you request no longer exists for whatever reason.
120
- // The correct behavior here is to refetch the shape from scratch and to reset the local state.
121
- error.response.status === 400 ? initRoom : Effect.fail(error),
122
- ),
123
- Effect.retry({ times: 1 }),
124
- )
125
-
126
- const headers = yield* HttpClientResponse.schemaHeaders(ResponseHeaders)(resp)
127
- const nextCursor = {
128
- offset: headers['x-electric-chunk-last-offset'],
129
- shapeId: headers['x-electric-shape-id'],
130
- }
131
-
132
- // Electric completes the long-poll request after ~20 seconds with a 204 status
133
- // In this case we just retry where we left off
134
- if (resp.status === 204) {
135
- return Option.some([Chunk.empty(), Option.some(nextCursor)] as const)
136
- }
137
-
138
- const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem))(resp)
139
-
140
- const items = body
141
- .filter((item) => item.value !== undefined)
142
- .map((item) => ({
143
- metadata: Option.some({ offset: item.offset!, shapeId: nextCursor.shapeId }),
144
- mutationEventEncoded: {
145
- mutation: item.value!.mutation,
146
- args: JSON.parse(item.value!.args),
147
- id: item.value!.id,
148
- parentId: item.value!.parentId,
149
- },
150
- }))
151
-
152
- // // TODO implement proper `remaining` handling
153
- // remaining: 0,
154
-
155
- // if (listenForNew === false && items.length === 0) {
156
- // return Option.none()
157
- // }
158
-
159
- const [newItems, pendingPushItems] = Chunk.fromIterable(items).pipe(
160
- Chunk.partition((item) => pendingPushDeferredMap.has(item.mutationEventEncoded.id)),
161
- )
162
-
163
- for (const item of pendingPushItems) {
164
- const deferred = pendingPushDeferredMap.get(item.mutationEventEncoded.id)!
165
- yield* Deferred.succeed(deferred, Option.getOrThrow(item.metadata))
166
- }
167
-
168
- return Option.some([newItems, Option.some(nextCursor)] as const)
169
- }).pipe(
170
- Effect.scoped,
171
- Effect.mapError((cause) => InvalidPullError.make({ message: cause.toString() })),
172
- )
173
-
174
- return {
175
- pull: (args) =>
176
- Stream.unfoldChunkEffect(
177
- args.pipe(
178
- Option.map((_) => _.metadata),
179
- Option.flatten,
180
- ),
181
- (metadataOption) => pull(metadataOption),
182
- ).pipe(
183
- Stream.chunks,
184
- Stream.map((chunk) => ({ batch: [...chunk], remaining: 0 })),
185
- ),
186
-
187
- push: (batch) =>
197
+ export const makeSyncBackend =
198
+ ({ endpoint }: SyncBackendOptions): SyncBackendConstructor<SyncMetadata> =>
199
+ ({ storeId, payload }) =>
200
+ Effect.gen(function* () {
201
+ const isConnected = yield* SubscriptionRef.make(true)
202
+ const pullEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.pull
203
+ const pushEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.push
204
+
205
+ const pull = (
206
+ handle: Option.Option<SyncMetadata>,
207
+ ): Effect.Effect<
208
+ Option.Option<
209
+ readonly [
210
+ Chunk.Chunk<{
211
+ metadata: Option.Option<SyncMetadata>
212
+ eventEncoded: LiveStoreEvent.AnyEncodedGlobal
213
+ }>,
214
+ Option.Option<SyncMetadata>,
215
+ ]
216
+ >,
217
+ InvalidPullError | IsOfflineError,
218
+ HttpClient.HttpClient
219
+ > =>
188
220
  Effect.gen(function* () {
189
- const deferreds: Deferred.Deferred<SyncMetadata>[] = []
190
- for (const mutationEventEncoded of batch) {
191
- const deferred = yield* Deferred.make<SyncMetadata>()
192
- pendingPushDeferredMap.set(mutationEventEncoded.id, deferred)
193
- deferreds.push(deferred)
221
+ const argsJson = yield* Schema.encode(Schema.parseJson(ApiSchema.PullPayload))(
222
+ ApiSchema.PullPayload.make({ storeId, handle, payload }),
223
+ )
224
+ const url = `${pullEndpoint}?args=${argsJson}`
225
+
226
+ const resp = yield* HttpClient.get(url)
227
+
228
+ if (resp.status === 401) {
229
+ const body = yield* resp.text.pipe(Effect.catchAll(() => Effect.succeed('-')))
230
+ return yield* InvalidPullError.make({
231
+ message: `Unauthorized (401): Couldn't connect to ElectricSQL: ${body}`,
232
+ })
233
+ } else if (resp.status === 409) {
234
+ // https://electric-sql.com/openapi.html#/paths/~1v1~1shape/get
235
+ // {
236
+ // "message": "The shape associated with this shape_handle and offset was not found. Resync to fetch the latest shape",
237
+ // "shape_handle": "2494_84241",
238
+ // "offset": "-1"
239
+ // }
240
+
241
+ // TODO: implementation plan:
242
+ // start pulling events from scratch with the new handle and ignore the "old events"
243
+ // until we found a new event, then, continue with the new handle
244
+ return notYetImplemented(`Electric shape not found`)
245
+ } else if (resp.status < 200 || resp.status >= 300) {
246
+ return yield* InvalidPullError.make({
247
+ message: `Unexpected status code: ${resp.status}`,
248
+ })
194
249
  }
195
250
 
196
- const resp = yield* HttpClientRequest.schemaBodyJson(ApiPushEventPayload)(
197
- HttpClientRequest.post(pushEventEndpoint),
198
- ApiPushEventPayload.make({ roomId, batch }),
199
- ).pipe(
200
- Effect.andThen(HttpClient.execute),
201
- Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
202
- Effect.scoped,
203
- Effect.mapError((cause) =>
204
- InvalidPushError.make({ reason: { _tag: 'Unexpected', message: cause.toString() } }),
205
- ),
206
- )
251
+ const headers = yield* HttpClientResponse.schemaHeaders(ResponseHeaders)(resp)
252
+ const nextHandle = {
253
+ offset: headers['electric-offset'],
254
+ handle: headers['electric-handle'],
255
+ }
207
256
 
208
- if (!resp.success) {
209
- yield* InvalidPushError.make({ reason: { _tag: 'Unexpected', message: 'Push failed' } })
257
+ // Electric completes the long-poll request after ~20 seconds with a 204 status
258
+ // In this case we just retry where we left off
259
+ if (resp.status === 204) {
260
+ return Option.some([Chunk.empty(), Option.some(nextHandle)] as const)
210
261
  }
211
262
 
212
- const metadata = yield* Effect.all(deferreds, { concurrency: 'unbounded' }).pipe(
213
- Effect.map((_) => _.map(Option.some)),
214
- )
263
+ const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem), {
264
+ onExcessProperty: 'preserve',
265
+ })(resp)
266
+
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
+ }))
273
+
274
+ // // TODO implement proper `remaining` handling
275
+ // remaining: 0,
276
+
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)
282
+ }).pipe(
283
+ Effect.scoped,
284
+ Effect.mapError((cause) =>
285
+ cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ message: cause.toString() }),
286
+ ),
287
+ )
215
288
 
216
- for (const mutationEventEncoded of batch) {
217
- pendingPushDeferredMap.delete(mutationEventEncoded.id)
218
- }
289
+ const pullEndpointHasSameOrigin =
290
+ pullEndpoint.startsWith('/') ||
291
+ (globalThis.location !== undefined && globalThis.location.origin === new URL(pullEndpoint).origin)
292
+
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),
306
+ ).pipe(
307
+ Stream.chunks,
308
+ Stream.map((chunk) => ({ batch: [...chunk], remaining: 0 })),
309
+ ),
219
310
 
220
- return { metadata }
221
- }),
222
- isConnected,
223
- } satisfies SyncBackend<SyncMetadata>
224
- })
311
+ push: (batch) =>
312
+ Effect.gen(function* () {
313
+ const resp = yield* HttpClientRequest.schemaBodyJson(ApiSchema.PushPayload)(
314
+ HttpClientRequest.post(pushEndpoint),
315
+ ApiSchema.PushPayload.make({ storeId, batch }),
316
+ ).pipe(
317
+ Effect.andThen(HttpClient.execute),
318
+ Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
319
+ Effect.scoped,
320
+ Effect.mapError((cause) =>
321
+ InvalidPushError.make({ reason: { _tag: 'Unexpected', message: cause.toString() } }),
322
+ ),
323
+ )
324
+
325
+ if (!resp.success) {
326
+ yield* InvalidPushError.make({ reason: { _tag: 'Unexpected', message: 'Push failed' } })
327
+ }
328
+ }),
329
+ isConnected,
330
+ metadata: {
331
+ name: '@livestore/sync-electric',
332
+ description: 'LiveStore sync backend implementation using ElectricSQL',
333
+ protocol: 'http',
334
+ endpoint,
335
+ },
336
+ } satisfies SyncBackend<SyncMetadata>
337
+ })
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
+ }
package/tsconfig.json DELETED
@@ -1,10 +0,0 @@
1
- {
2
- "extends": "../../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "outDir": "./dist",
5
- "rootDir": "./src",
6
- "tsBuildInfoFile": "./dist/.tsbuildinfo"
7
- },
8
- "include": ["./src"],
9
- "references": [{ "path": "../common" }, { "path": "../utils" }]
10
- }