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

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,207 @@ 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
- })
102
+ export const syncBackendOptions = <TOptions extends SyncBackendOptions>(options: TOptions) => options
51
103
 
52
- export const ApiInitRoomPayload = Schema.TaggedStruct('sync-electric.InitRoom', {
53
- roomId: Schema.String,
54
- })
104
+ export const makeElectricUrl = (electricHost: string, searchParams: URLSearchParams) => {
105
+ const endpointUrl = `${electricHost}/v1/shape`
106
+ const argsResult = Schema.decodeUnknownEither(Schema.Struct({ args: Schema.parseJson(ApiSchema.PullPayload) }))(
107
+ Object.fromEntries(searchParams.entries()),
108
+ )
55
109
 
56
- export const ApiPayload = Schema.Union(ApiPushEventPayload, ApiInitRoomPayload)
110
+ if (argsResult._tag === 'Left') {
111
+ return shouldNeverHappen('Invalid search params', searchParams)
112
+ }
57
113
 
58
- export const syncBackendOptions = <TOptions extends SyncBackendOptions>(options: TOptions) => options
114
+ const args = argsResult.right.args
115
+ const tableName = toTableName(args.storeId)
116
+ const url =
117
+ args.handle._tag === 'None'
118
+ ? `${endpointUrl}?table=${tableName}&offset=-1`
119
+ : `${endpointUrl}?table=${tableName}&offset=${args.handle.value.offset}&handle=${args.handle.value.handle}&live=true`
59
120
 
60
- export interface SyncBackendOptions extends SyncBackendOptionsBase {
61
- type: 'electric'
62
- /**
63
- * The host of the Electric server
64
- *
65
- * @example "https://localhost:3000"
66
- */
67
- electricHost: string
68
- roomId: string
121
+ return { url, storeId: args.storeId, needsInit: args.handle._tag === 'None' }
122
+ }
123
+
124
+ export interface SyncBackendOptions {
125
+ storeId: string
69
126
  /**
70
- * The POST endpoint to push events to
127
+ * The endpoint to pull/push events. Pull is a `GET` request, push is a `POST` request.
128
+ * Usually this endpoint is part of your API layer to proxy requests to the Electric server
129
+ * e.g. to implement auth, rate limiting, etc.
71
130
  *
72
- * @example "/api/push-event"
73
- * @example "https://api.myapp.com/push-event"
131
+ * @example "/api/electric"
132
+ * @example { push: "/api/push-event", pull: "/api/pull-event" }
74
133
  */
75
- pushEventEndpoint: string
76
- }
77
-
78
- interface LiveStoreGlobalElectric {
79
- syncBackend: SyncBackendOptions
134
+ endpoint:
135
+ | string
136
+ | {
137
+ push: string
138
+ pull: string
139
+ }
80
140
  }
81
141
 
82
- declare global {
83
- interface LiveStoreGlobal extends LiveStoreGlobalElectric {}
84
- }
142
+ export const SyncMetadata = Schema.Struct({
143
+ offset: Schema.String,
144
+ // TODO move this into some kind of "global" sync metadata as it's the same for each event
145
+ handle: Schema.String,
146
+ })
85
147
 
86
148
  type SyncMetadata = {
87
149
  offset: string
88
150
  // TODO move this into some kind of "global" sync metadata as it's the same for each event
89
- shapeId: string
151
+ handle: string
90
152
  }
91
153
 
92
154
  export const makeSyncBackend = ({
93
- electricHost,
94
- roomId,
95
- pushEventEndpoint,
155
+ storeId,
156
+ endpoint,
96
157
  }: SyncBackendOptions): Effect.Effect<SyncBackend<SyncMetadata>, never, Scope.Scope> =>
97
158
  Effect.gen(function* () {
98
- const endpointUrl = `${electricHost}/v1/shape/events_${roomId}`
99
-
100
159
  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))
160
+ const pullEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.pull
161
+ const pushEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.push
106
162
 
107
163
  // TODO check whether we still need this
108
- const pendingPushDeferredMap = new Map<string, Deferred.Deferred<SyncMetadata>>()
109
-
110
- const pull = (args: Option.Option<SyncMetadata>) =>
164
+ const pendingPushDeferredMap = new Map<EventId.GlobalEventId, Deferred.Deferred<SyncMetadata>>()
165
+
166
+ const pull = (
167
+ handle: Option.Option<SyncMetadata>,
168
+ ): Effect.Effect<
169
+ Option.Option<
170
+ readonly [
171
+ Chunk.Chunk<{ metadata: Option.Option<SyncMetadata>; mutationEventEncoded: MutationEvent.AnyEncodedGlobal }>,
172
+ Option.Option<SyncMetadata>,
173
+ ]
174
+ >,
175
+ InvalidPullError | IsOfflineError,
176
+ HttpClient.HttpClient
177
+ > =>
111
178
  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 }),
179
+ const argsJson = yield* Schema.encode(Schema.parseJson(ApiSchema.PullPayload))(
180
+ ApiSchema.PullPayload.make({ storeId, handle }),
124
181
  )
182
+ const url = `${pullEndpoint}?args=${argsJson}`
183
+
184
+ const resp = yield* HttpClient.get(url)
125
185
 
126
186
  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'],
187
+ const nextHandle = {
188
+ offset: headers['electric-offset'],
189
+ handle: headers['electric-handle'],
190
+ }
191
+
192
+ // TODO handle case where Electric shape is not found for a given handle
193
+ // https://electric-sql.com/openapi.html#/paths/~1v1~1shape/get
194
+ // {
195
+ // "message": "The shape associated with this shape_handle and offset was not found. Resync to fetch the latest shape",
196
+ // "shape_handle": "2494_84241",
197
+ // "offset": "-1"
198
+ // }
199
+ if (resp.status === 409) {
200
+ // TODO: implementation plan:
201
+ // start pulling events from scratch with the new handle and ignore the "old events"
202
+ // until we found a new event, then, continue with the new handle
203
+ return notYetImplemented(`Electric shape not found for handle ${nextHandle.handle}`)
130
204
  }
131
205
 
132
206
  // Electric completes the long-poll request after ~20 seconds with a 204 status
133
207
  // In this case we just retry where we left off
134
208
  if (resp.status === 204) {
135
- return Option.some([Chunk.empty(), Option.some(nextCursor)] as const)
209
+ return Option.some([Chunk.empty(), Option.some(nextHandle)] as const)
136
210
  }
137
211
 
138
- const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem))(resp)
212
+ const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem), {
213
+ onExcessProperty: 'preserve',
214
+ })(resp)
139
215
 
140
216
  const items = body
141
- .filter((item) => item.value !== undefined)
217
+ .filter((item) => item.value !== undefined && (item.headers as any).operation === 'insert')
142
218
  .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
- },
219
+ metadata: Option.some({ offset: nextHandle.offset!, handle: nextHandle.handle }),
220
+ mutationEventEncoded: item.value! as MutationEvent.AnyEncodedGlobal,
150
221
  }))
151
222
 
152
223
  // // TODO implement proper `remaining` handling
@@ -156,16 +227,14 @@ export const makeSyncBackend = ({
156
227
  // return Option.none()
157
228
  // }
158
229
 
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))
230
+ for (const item of items) {
231
+ const deferred = pendingPushDeferredMap.get(item.mutationEventEncoded.id)
232
+ if (deferred !== undefined) {
233
+ yield* Deferred.succeed(deferred, Option.getOrThrow(item.metadata))
234
+ }
166
235
  }
167
236
 
168
- return Option.some([newItems, Option.some(nextCursor)] as const)
237
+ return Option.some([Chunk.fromIterable(items), Option.some(nextHandle)] as const)
169
238
  }).pipe(
170
239
  Effect.scoped,
171
240
  Effect.mapError((cause) => InvalidPullError.make({ message: cause.toString() })),
@@ -189,13 +258,13 @@ export const makeSyncBackend = ({
189
258
  const deferreds: Deferred.Deferred<SyncMetadata>[] = []
190
259
  for (const mutationEventEncoded of batch) {
191
260
  const deferred = yield* Deferred.make<SyncMetadata>()
192
- pendingPushDeferredMap.set(eventIdToString(mutationEventEncoded.id), deferred)
261
+ pendingPushDeferredMap.set(mutationEventEncoded.id, deferred)
193
262
  deferreds.push(deferred)
194
263
  }
195
264
 
196
- const resp = yield* HttpClientRequest.schemaBodyJson(ApiPushEventPayload)(
197
- HttpClientRequest.post(pushEventEndpoint),
198
- ApiPushEventPayload.make({ roomId, batch }),
265
+ const resp = yield* HttpClientRequest.schemaBodyJson(ApiSchema.PushPayload)(
266
+ HttpClientRequest.post(pushEndpoint),
267
+ ApiSchema.PushPayload.make({ storeId, batch }),
199
268
  ).pipe(
200
269
  Effect.andThen(HttpClient.execute),
201
270
  Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
@@ -214,7 +283,7 @@ export const makeSyncBackend = ({
214
283
  )
215
284
 
216
285
  for (const mutationEventEncoded of batch) {
217
- pendingPushDeferredMap.delete(eventIdToString(mutationEventEncoded.id))
286
+ pendingPushDeferredMap.delete(mutationEventEncoded.id)
218
287
  }
219
288
 
220
289
  return { metadata }
@@ -223,4 +292,14 @@ export const makeSyncBackend = ({
223
292
  } satisfies SyncBackend<SyncMetadata>
224
293
  })
225
294
 
226
- const eventIdToString = (eventId: EventId.EventId) => `${eventId.global}_${eventId.local}`
295
+ /**
296
+ * Needs to be bumped when the storage format changes (e.g. mutationLogTable schema changes)
297
+ *
298
+ * Changing this version number will lead to a "soft reset".
299
+ */
300
+ export const PERSISTENCE_FORMAT_VERSION = 3
301
+
302
+ export const toTableName = (storeId: string) => {
303
+ const escapedStoreId = storeId.replaceAll(/[^a-zA-Z0-9_]/g, '_')
304
+ return `mutation_log_${PERSISTENCE_FORMAT_VERSION}_${escapedStoreId}`
305
+ }
package/tmp/pack.tgz ADDED
Binary file