@livestore/sync-electric 0.3.0-dev.12 → 0.3.0-dev.14

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 } 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,128 +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.AnyEncodedGlobal),
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
- storeId: Schema.String,
49
- batch: Schema.Array(MutationEvent.AnyEncodedGlobal),
50
- })
102
+ export const syncBackendOptions = <TOptions extends SyncBackendOptions>(options: TOptions) => options
51
103
 
52
- export const ApiInitRoomPayload = Schema.TaggedStruct('sync-electric.InitRoom', {
53
- storeId: 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`
120
+
121
+ return { url, storeId: args.storeId, needsInit: args.handle._tag === 'None' }
122
+ }
59
123
 
60
124
  export interface SyncBackendOptions {
61
- /**
62
- * The host of the Electric server
63
- *
64
- * @example "https://localhost:3000"
65
- */
66
- electricHost: string
67
125
  storeId: string
68
126
  /**
69
- * 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.
70
130
  *
71
- * @example "/api/push-event"
72
- * @example "https://api.myapp.com/push-event"
131
+ * @example "/api/electric"
132
+ * @example { push: "/api/push-event", pull: "/api/pull-event" }
73
133
  */
74
- pushEventEndpoint: string
134
+ endpoint:
135
+ | string
136
+ | {
137
+ push: string
138
+ pull: string
139
+ }
75
140
  }
76
141
 
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
+ })
147
+
77
148
  type SyncMetadata = {
78
149
  offset: string
79
150
  // TODO move this into some kind of "global" sync metadata as it's the same for each event
80
- shapeId: string
151
+ handle: string
81
152
  }
82
153
 
83
154
  export const makeSyncBackend = ({
84
- electricHost,
85
155
  storeId,
86
- pushEventEndpoint,
156
+ endpoint,
87
157
  }: SyncBackendOptions): Effect.Effect<SyncBackend<SyncMetadata>, never, Scope.Scope> =>
88
158
  Effect.gen(function* () {
89
- const endpointUrl = `${electricHost}/v1/shape/events_${storeId}`
90
-
91
159
  const isConnected = yield* SubscriptionRef.make(true)
92
-
93
- const initRoom = HttpClientRequest.schemaBodyJson(ApiInitRoomPayload)(
94
- HttpClientRequest.post(pushEventEndpoint),
95
- ApiInitRoomPayload.make({ storeId }),
96
- ).pipe(Effect.andThen(HttpClient.execute))
160
+ const pullEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.pull
161
+ const pushEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.push
97
162
 
98
163
  // TODO check whether we still need this
99
164
  const pendingPushDeferredMap = new Map<EventId.GlobalEventId, Deferred.Deferred<SyncMetadata>>()
100
165
 
101
- const pull = (args: Option.Option<SyncMetadata>) =>
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
+ > =>
102
178
  Effect.gen(function* () {
103
- const url =
104
- args._tag === 'None'
105
- ? `${endpointUrl}?offset=-1`
106
- : `${endpointUrl}?offset=${args.value.offset}&shape_id=${args.value.shapeId}&live=true`
107
-
108
- const resp = yield* HttpClient.get(url).pipe(
109
- Effect.tapErrorTag('ResponseError', (error) =>
110
- // TODO handle 409 error when the shapeId you request no longer exists for whatever reason.
111
- // The correct behavior here is to refetch the shape from scratch and to reset the local state.
112
- error.response.status === 400 ? initRoom : Effect.fail(error),
113
- ),
114
- Effect.retry({ times: 1 }),
179
+ const argsJson = yield* Schema.encode(Schema.parseJson(ApiSchema.PullPayload))(
180
+ ApiSchema.PullPayload.make({ storeId, handle }),
115
181
  )
182
+ const url = `${pullEndpoint}?args=${argsJson}`
183
+
184
+ const resp = yield* HttpClient.get(url)
116
185
 
117
186
  const headers = yield* HttpClientResponse.schemaHeaders(ResponseHeaders)(resp)
118
- const nextCursor = {
119
- offset: headers['x-electric-chunk-last-offset'],
120
- 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}`)
121
204
  }
122
205
 
123
206
  // Electric completes the long-poll request after ~20 seconds with a 204 status
124
207
  // In this case we just retry where we left off
125
208
  if (resp.status === 204) {
126
- return Option.some([Chunk.empty(), Option.some(nextCursor)] as const)
209
+ return Option.some([Chunk.empty(), Option.some(nextHandle)] as const)
127
210
  }
128
211
 
129
- const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem))(resp)
212
+ const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem), {
213
+ onExcessProperty: 'preserve',
214
+ })(resp)
130
215
 
131
216
  const items = body
132
- .filter((item) => item.value !== undefined)
217
+ .filter((item) => item.value !== undefined && (item.headers as any).operation === 'insert')
133
218
  .map((item) => ({
134
- metadata: Option.some({ offset: item.offset!, shapeId: nextCursor.shapeId }),
135
- mutationEventEncoded: {
136
- mutation: item.value!.mutation,
137
- args: JSON.parse(item.value!.args),
138
- id: item.value!.id,
139
- parentId: item.value!.parentId,
140
- },
219
+ metadata: Option.some({ offset: nextHandle.offset!, handle: nextHandle.handle }),
220
+ mutationEventEncoded: item.value! as MutationEvent.AnyEncodedGlobal,
141
221
  }))
142
222
 
143
223
  // // TODO implement proper `remaining` handling
@@ -147,16 +227,14 @@ export const makeSyncBackend = ({
147
227
  // return Option.none()
148
228
  // }
149
229
 
150
- const [newItems, pendingPushItems] = Chunk.fromIterable(items).pipe(
151
- Chunk.partition((item) => pendingPushDeferredMap.has(item.mutationEventEncoded.id)),
152
- )
153
-
154
- for (const item of pendingPushItems) {
155
- const deferred = pendingPushDeferredMap.get(item.mutationEventEncoded.id)!
156
- 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
+ }
157
235
  }
158
236
 
159
- return Option.some([newItems, Option.some(nextCursor)] as const)
237
+ return Option.some([Chunk.fromIterable(items), Option.some(nextHandle)] as const)
160
238
  }).pipe(
161
239
  Effect.scoped,
162
240
  Effect.mapError((cause) => InvalidPullError.make({ message: cause.toString() })),
@@ -184,9 +262,9 @@ export const makeSyncBackend = ({
184
262
  deferreds.push(deferred)
185
263
  }
186
264
 
187
- const resp = yield* HttpClientRequest.schemaBodyJson(ApiPushEventPayload)(
188
- HttpClientRequest.post(pushEventEndpoint),
189
- ApiPushEventPayload.make({ storeId, batch }),
265
+ const resp = yield* HttpClientRequest.schemaBodyJson(ApiSchema.PushPayload)(
266
+ HttpClientRequest.post(pushEndpoint),
267
+ ApiSchema.PushPayload.make({ storeId, batch }),
190
268
  ).pipe(
191
269
  Effect.andThen(HttpClient.execute),
192
270
  Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
@@ -213,3 +291,15 @@ export const makeSyncBackend = ({
213
291
  isConnected,
214
292
  } satisfies SyncBackend<SyncMetadata>
215
293
  })
294
+
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
+ }