@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/dist/.tsbuildinfo +1 -1
- package/dist/api-schema.d.ts +35 -0
- package/dist/api-schema.d.ts.map +1 -0
- package/dist/api-schema.js +15 -0
- package/dist/api-schema.js.map +1 -0
- package/dist/index.d.ts +42 -79
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +138 -51
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
- package/src/api-schema.ts +19 -0
- package/src/index.ts +203 -93
- package/tmp/pack.tgz +0 -0
package/src/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
[
|
|
23
|
-
|
|
24
|
-
|
|
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.
|
|
79
|
+
/** Postgres path (e.g. `"public"."events_9069baf0_b3e6_42f7_980f_188416eab3fx3"/"0"`) */
|
|
32
80
|
key: Schema.optional(Schema.String),
|
|
33
|
-
value: Schema.optional(
|
|
34
|
-
headers: Schema.
|
|
35
|
-
|
|
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
|
-
'
|
|
40
|
-
// '
|
|
94
|
+
'electric-handle': Schema.String,
|
|
95
|
+
// 'electric-schema': Schema.parseJson(Schema.Any),
|
|
41
96
|
/** e.g. 26799576_0 */
|
|
42
|
-
'
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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
|
-
|
|
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
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
182
|
+
handle: string
|
|
90
183
|
}
|
|
91
184
|
|
|
92
185
|
export const makeSyncBackend = ({
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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<
|
|
109
|
-
|
|
110
|
-
const pull = (
|
|
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
|
|
113
|
-
|
|
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
|
|
128
|
-
offset: headers['
|
|
129
|
-
|
|
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(
|
|
240
|
+
return Option.some([Chunk.empty(), Option.some(nextHandle)] as const)
|
|
136
241
|
}
|
|
137
242
|
|
|
138
|
-
const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem)
|
|
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:
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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([
|
|
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(
|
|
292
|
+
pendingPushDeferredMap.set(mutationEventEncoded.id, deferred)
|
|
193
293
|
deferreds.push(deferred)
|
|
194
294
|
}
|
|
195
295
|
|
|
196
|
-
const resp = yield* HttpClientRequest.schemaBodyJson(
|
|
197
|
-
HttpClientRequest.post(
|
|
198
|
-
|
|
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(
|
|
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
|
-
|
|
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
|