@livestore/sync-electric 0.3.0-dev.2 → 0.3.0-dev.22

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,7 +1,8 @@
1
- import type { SyncBackend, SyncBackendOptionsBase } from '@livestore/common'
1
+ import type { IsOfflineError, SyncBackend } 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'
5
+ import { notYetImplemented, shouldNeverHappen } from '@livestore/utils'
5
6
  import type { Scope } from '@livestore/utils/effect'
6
7
  import {
7
8
  Chunk,
@@ -16,137 +17,238 @@ import {
16
17
  SubscriptionRef,
17
18
  } from '@livestore/utils/effect'
18
19
 
20
+ import * as ApiSchema from './api-schema.js'
21
+
22
+ export * as ApiSchema from './api-schema.js'
23
+
19
24
  /*
20
25
  Example data:
21
26
 
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"}}]
27
+ [
28
+ {
29
+ "value": {
30
+ "args": "{\"id\": \"127c3df4-0855-4587-ae75-14463f4a3aa0\", \"text\": \"1\"}",
31
+ "clientId": "S_YOa",
32
+ "id": "0",
33
+ "mutation": "todoCreated",
34
+ "parentId": "-1"
35
+ },
36
+ "key": "\"public\".\"events_9069baf0_b3e6_42f7_980f_188416eab3fx3\"/\"0\"",
37
+ "headers": {
38
+ "last": true,
39
+ "relation": [
40
+ "public",
41
+ "events_9069baf0_b3e6_42f7_980f_188416eab3fx3"
42
+ ],
43
+ "operation": "insert",
44
+ "lsn": 27294160,
45
+ "op_position": 0,
46
+ "txids": [
47
+ 753
48
+ ]
49
+ }
50
+ },
51
+ {
52
+ "headers": {
53
+ "control": "up-to-date",
54
+ "global_last_seen_lsn": 27294160
55
+ }
56
+ }
57
+ ]
58
+
25
59
 
26
60
  Also see: https://github.com/electric-sql/electric/blob/main/packages/typescript-client/src/client.ts
27
61
 
28
62
  */
29
63
 
64
+ const MutationEventGlobalFromStringRecord = Schema.Struct({
65
+ id: Schema.NumberFromString,
66
+ parentId: Schema.NumberFromString,
67
+ mutation: Schema.String,
68
+ args: Schema.parseJson(Schema.Any),
69
+ clientId: Schema.String,
70
+ sessionId: Schema.optional(Schema.String),
71
+ }).pipe(
72
+ Schema.transform(MutationEvent.AnyEncodedGlobal, {
73
+ decode: (_) => _,
74
+ encode: (_) => _,
75
+ }),
76
+ )
77
+
30
78
  const ResponseItem = Schema.Struct({
31
- /** Postgres path (e.g. "public.events/1") */
79
+ /** Postgres path (e.g. `"public"."events_9069baf0_b3e6_42f7_980f_188416eab3fx3"/"0"`) */
32
80
  key: Schema.optional(Schema.String),
33
- value: Schema.optional(MutationEvent.EncodedAny),
34
- headers: Schema.Record({ key: Schema.String, value: Schema.Any }),
35
- offset: Schema.optional(Schema.String),
81
+ value: Schema.optional(MutationEventGlobalFromStringRecord),
82
+ headers: Schema.Union(
83
+ Schema.Struct({
84
+ operation: Schema.Union(Schema.Literal('insert'), Schema.Literal('update'), Schema.Literal('delete')),
85
+ relation: Schema.Array(Schema.String),
86
+ }),
87
+ Schema.Struct({
88
+ control: Schema.String,
89
+ }),
90
+ ),
36
91
  })
37
92
 
38
93
  const ResponseHeaders = Schema.Struct({
39
- 'x-electric-shape-id': Schema.String,
40
- // 'x-electric-schema': Schema.parseJson(Schema.Any),
94
+ 'electric-handle': Schema.String,
95
+ // 'electric-schema': Schema.parseJson(Schema.Any),
41
96
  /** e.g. 26799576_0 */
42
- 'x-electric-chunk-last-offset': Schema.String,
97
+ 'electric-offset': Schema.String,
43
98
  })
44
99
 
45
100
  export const syncBackend = {} as any
46
101
 
47
- export const ApiPushEventPayload = Schema.TaggedStruct('sync-electric.PushEvent', {
48
- roomId: Schema.String,
49
- batch: Schema.Array(MutationEvent.EncodedAny),
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
102
  export const syncBackendOptions = <TOptions extends SyncBackendOptions>(options: TOptions) => options
59
103
 
60
- export interface SyncBackendOptions extends SyncBackendOptionsBase {
61
- type: 'electric'
62
- /**
63
- * The host of the Electric server
64
- *
65
- * @example "https://localhost:3000"
66
- */
104
+ export const makeElectricUrl = ({
105
+ electricHost,
106
+ searchParams: providedSearchParams,
107
+ sourceId,
108
+ sourceSecret,
109
+ }: {
67
110
  electricHost: string
68
- roomId: string
69
111
  /**
70
- * The POST endpoint to push events to
71
- *
72
- * @example "/api/push-event"
73
- * @example "https://api.myapp.com/push-event"
112
+ * Needed to extract information from the search params which the `@livestore/sync-electric`
113
+ * client implementation automatically adds:
114
+ * - `handle`: the ElectricSQL handle
115
+ * - `storeId`: the Livestore storeId
74
116
  */
75
- pushEventEndpoint: string
117
+ searchParams: URLSearchParams
118
+ /** Needed for Electric Cloud */
119
+ sourceId?: string
120
+ /** Needed for Electric Cloud */
121
+ sourceSecret?: string
122
+ }) => {
123
+ const endpointUrl = `${electricHost}/v1/shape`
124
+ const argsResult = Schema.decodeUnknownEither(Schema.Struct({ args: Schema.parseJson(ApiSchema.PullPayload) }))(
125
+ Object.fromEntries(providedSearchParams.entries()),
126
+ )
127
+
128
+ if (argsResult._tag === 'Left') {
129
+ return shouldNeverHappen('Invalid search params provided to makeElectricUrl', providedSearchParams)
130
+ }
131
+
132
+ const args = argsResult.right.args
133
+ const tableName = toTableName(args.storeId)
134
+ const searchParams = new URLSearchParams()
135
+ searchParams.set('table', tableName)
136
+ if (sourceId !== undefined) {
137
+ searchParams.set('source_id', sourceId)
138
+ }
139
+ if (sourceSecret !== undefined) {
140
+ searchParams.set('source_secret', sourceSecret)
141
+ }
142
+ if (args.handle._tag === 'None') {
143
+ searchParams.set('offset', '-1')
144
+ } else {
145
+ searchParams.set('offset', args.handle.value.offset)
146
+ searchParams.set('handle', args.handle.value.handle)
147
+ searchParams.set('live', 'true')
148
+ }
149
+
150
+ const url = `${endpointUrl}?${searchParams.toString()}`
151
+
152
+ return { url, storeId: args.storeId, needsInit: args.handle._tag === 'None' }
76
153
  }
77
154
 
78
- interface LiveStoreGlobalElectric {
79
- syncBackend: SyncBackendOptions
155
+ export interface SyncBackendOptions {
156
+ storeId: string
157
+ /**
158
+ * The endpoint to pull/push events. Pull is a `GET` request, push is a `POST` request.
159
+ * Usually this endpoint is part of your API layer to proxy requests to the Electric server
160
+ * e.g. to implement auth, rate limiting, etc.
161
+ *
162
+ * @example "/api/electric"
163
+ * @example { push: "/api/push-event", pull: "/api/pull-event" }
164
+ */
165
+ endpoint:
166
+ | string
167
+ | {
168
+ push: string
169
+ pull: string
170
+ }
80
171
  }
81
172
 
82
- declare global {
83
- interface LiveStoreGlobal extends LiveStoreGlobalElectric {}
84
- }
173
+ export const SyncMetadata = Schema.Struct({
174
+ offset: Schema.String,
175
+ // TODO move this into some kind of "global" sync metadata as it's the same for each event
176
+ handle: Schema.String,
177
+ })
85
178
 
86
179
  type SyncMetadata = {
87
180
  offset: string
88
181
  // TODO move this into some kind of "global" sync metadata as it's the same for each event
89
- shapeId: string
182
+ handle: string
90
183
  }
91
184
 
92
185
  export const makeSyncBackend = ({
93
- electricHost,
94
- roomId,
95
- pushEventEndpoint,
186
+ storeId,
187
+ endpoint,
96
188
  }: SyncBackendOptions): Effect.Effect<SyncBackend<SyncMetadata>, never, Scope.Scope> =>
97
189
  Effect.gen(function* () {
98
- const endpointUrl = `${electricHost}/v1/shape/events_${roomId}`
99
-
100
190
  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))
191
+ const pullEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.pull
192
+ const pushEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.push
106
193
 
107
194
  // TODO check whether we still need this
108
- const pendingPushDeferredMap = new Map<string, Deferred.Deferred<SyncMetadata>>()
109
-
110
- const pull = (args: Option.Option<SyncMetadata>) =>
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
+ > =>
111
209
  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 }),
210
+ const argsJson = yield* Schema.encode(Schema.parseJson(ApiSchema.PullPayload))(
211
+ ApiSchema.PullPayload.make({ storeId, handle }),
124
212
  )
213
+ const url = `${pullEndpoint}?args=${argsJson}`
214
+
215
+ const resp = yield* HttpClient.get(url)
125
216
 
126
217
  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'],
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}`)
130
235
  }
131
236
 
132
237
  // Electric completes the long-poll request after ~20 seconds with a 204 status
133
238
  // In this case we just retry where we left off
134
239
  if (resp.status === 204) {
135
- return Option.some([Chunk.empty(), Option.some(nextCursor)] as const)
240
+ return Option.some([Chunk.empty(), Option.some(nextHandle)] as const)
136
241
  }
137
242
 
138
- const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem))(resp)
243
+ const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem), {
244
+ onExcessProperty: 'preserve',
245
+ })(resp)
139
246
 
140
247
  const items = body
141
- .filter((item) => item.value !== undefined)
248
+ .filter((item) => item.value !== undefined && (item.headers as any).operation === 'insert')
142
249
  .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
- },
250
+ metadata: Option.some({ offset: nextHandle.offset!, handle: nextHandle.handle }),
251
+ mutationEventEncoded: item.value! as MutationEvent.AnyEncodedGlobal,
150
252
  }))
151
253
 
152
254
  // // TODO implement proper `remaining` handling
@@ -156,16 +258,14 @@ export const makeSyncBackend = ({
156
258
  // return Option.none()
157
259
  // }
158
260
 
159
- const [newItems, pendingPushItems] = Chunk.fromIterable(items).pipe(
160
- Chunk.partition((item) => pendingPushDeferredMap.has(eventIdToString(item.mutationEventEncoded.id))),
161
- )
162
-
163
- for (const item of pendingPushItems) {
164
- const deferred = pendingPushDeferredMap.get(eventIdToString(item.mutationEventEncoded.id))!
165
- yield* Deferred.succeed(deferred, Option.getOrThrow(item.metadata))
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))
265
+ }
166
266
  }
167
267
 
168
- return Option.some([newItems, Option.some(nextCursor)] as const)
268
+ return Option.some([Chunk.fromIterable(items), Option.some(nextHandle)] as const)
169
269
  }).pipe(
170
270
  Effect.scoped,
171
271
  Effect.mapError((cause) => InvalidPullError.make({ message: cause.toString() })),
@@ -189,13 +289,13 @@ export const makeSyncBackend = ({
189
289
  const deferreds: Deferred.Deferred<SyncMetadata>[] = []
190
290
  for (const mutationEventEncoded of batch) {
191
291
  const deferred = yield* Deferred.make<SyncMetadata>()
192
- pendingPushDeferredMap.set(eventIdToString(mutationEventEncoded.id), deferred)
292
+ pendingPushDeferredMap.set(mutationEventEncoded.id, deferred)
193
293
  deferreds.push(deferred)
194
294
  }
195
295
 
196
- const resp = yield* HttpClientRequest.schemaBodyJson(ApiPushEventPayload)(
197
- HttpClientRequest.post(pushEventEndpoint),
198
- ApiPushEventPayload.make({ roomId, batch }),
296
+ const resp = yield* HttpClientRequest.schemaBodyJson(ApiSchema.PushPayload)(
297
+ HttpClientRequest.post(pushEndpoint),
298
+ ApiSchema.PushPayload.make({ storeId, batch }),
199
299
  ).pipe(
200
300
  Effect.andThen(HttpClient.execute),
201
301
  Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
@@ -214,7 +314,7 @@ export const makeSyncBackend = ({
214
314
  )
215
315
 
216
316
  for (const mutationEventEncoded of batch) {
217
- pendingPushDeferredMap.delete(eventIdToString(mutationEventEncoded.id))
317
+ pendingPushDeferredMap.delete(mutationEventEncoded.id)
218
318
  }
219
319
 
220
320
  return { metadata }
@@ -223,4 +323,14 @@ export const makeSyncBackend = ({
223
323
  } satisfies SyncBackend<SyncMetadata>
224
324
  })
225
325
 
226
- const eventIdToString = (eventId: EventId.EventId) => `${eventId.global}_${eventId.local}`
326
+ /**
327
+ * Needs to be bumped when the storage format changes (e.g. mutationLogTable schema changes)
328
+ *
329
+ * Changing this version number will lead to a "soft reset".
330
+ */
331
+ export const PERSISTENCE_FORMAT_VERSION = 3
332
+
333
+ export const toTableName = (storeId: string) => {
334
+ const escapedStoreId = storeId.replaceAll(/[^a-zA-Z0-9_]/g, '_')
335
+ return `mutation_log_${PERSISTENCE_FORMAT_VERSION}_${escapedStoreId}`
336
+ }
package/tmp/pack.tgz ADDED
Binary file