@livestore/sync-electric 0.3.0-dev.23 → 0.3.0-dev.25
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 +14 -4
- package/dist/api-schema.d.ts.map +1 -1
- package/dist/api-schema.js +2 -2
- package/dist/api-schema.js.map +1 -1
- package/dist/index.d.ts +11 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -5
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/api-schema.ts +2 -2
- package/src/index.ts +160 -133
- package/tmp/pack.tgz +0 -0
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IsOfflineError, SyncBackend } from '@livestore/common'
|
|
1
|
+
import type { IsOfflineError, SyncBackend, SyncBackendConstructor } 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'
|
|
@@ -67,7 +67,7 @@ const MutationEventGlobalFromStringRecord = Schema.Struct({
|
|
|
67
67
|
mutation: Schema.String,
|
|
68
68
|
args: Schema.parseJson(Schema.Any),
|
|
69
69
|
clientId: Schema.String,
|
|
70
|
-
sessionId: Schema.
|
|
70
|
+
sessionId: Schema.String,
|
|
71
71
|
}).pipe(
|
|
72
72
|
Schema.transform(MutationEvent.AnyEncodedGlobal, {
|
|
73
73
|
decode: (_) => _,
|
|
@@ -101,11 +101,16 @@ export const syncBackend = {} as any
|
|
|
101
101
|
|
|
102
102
|
export const syncBackendOptions = <TOptions extends SyncBackendOptions>(options: TOptions) => options
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* This function should be called in a trusted environment (e.g. a proxy server) as it
|
|
106
|
+
* requires access to senstive information (e.g. `apiSecret` / `sourceSecret`).
|
|
107
|
+
*/
|
|
104
108
|
export const makeElectricUrl = ({
|
|
105
109
|
electricHost,
|
|
106
110
|
searchParams: providedSearchParams,
|
|
107
111
|
sourceId,
|
|
108
112
|
sourceSecret,
|
|
113
|
+
apiSecret,
|
|
109
114
|
}: {
|
|
110
115
|
electricHost: string
|
|
111
116
|
/**
|
|
@@ -119,6 +124,8 @@ export const makeElectricUrl = ({
|
|
|
119
124
|
sourceId?: string
|
|
120
125
|
/** Needed for Electric Cloud */
|
|
121
126
|
sourceSecret?: string
|
|
127
|
+
/** For self-hosted ElectricSQL */
|
|
128
|
+
apiSecret?: string
|
|
122
129
|
}) => {
|
|
123
130
|
const endpointUrl = `${electricHost}/v1/shape`
|
|
124
131
|
const argsResult = Schema.decodeUnknownEither(Schema.Struct({ args: Schema.parseJson(ApiSchema.PullPayload) }))(
|
|
@@ -126,7 +133,11 @@ export const makeElectricUrl = ({
|
|
|
126
133
|
)
|
|
127
134
|
|
|
128
135
|
if (argsResult._tag === 'Left') {
|
|
129
|
-
return shouldNeverHappen(
|
|
136
|
+
return shouldNeverHappen(
|
|
137
|
+
'Invalid search params provided to makeElectricUrl',
|
|
138
|
+
providedSearchParams,
|
|
139
|
+
Object.fromEntries(providedSearchParams.entries()),
|
|
140
|
+
)
|
|
130
141
|
}
|
|
131
142
|
|
|
132
143
|
const args = argsResult.right.args
|
|
@@ -139,6 +150,9 @@ export const makeElectricUrl = ({
|
|
|
139
150
|
if (sourceSecret !== undefined) {
|
|
140
151
|
searchParams.set('source_secret', sourceSecret)
|
|
141
152
|
}
|
|
153
|
+
if (apiSecret !== undefined) {
|
|
154
|
+
searchParams.set('api_secret', apiSecret)
|
|
155
|
+
}
|
|
142
156
|
if (args.handle._tag === 'None') {
|
|
143
157
|
searchParams.set('offset', '-1')
|
|
144
158
|
} else {
|
|
@@ -153,7 +167,6 @@ export const makeElectricUrl = ({
|
|
|
153
167
|
}
|
|
154
168
|
|
|
155
169
|
export interface SyncBackendOptions {
|
|
156
|
-
storeId: string
|
|
157
170
|
/**
|
|
158
171
|
* The endpoint to pull/push events. Pull is a `GET` request, push is a `POST` request.
|
|
159
172
|
* Usually this endpoint is part of your API layer to proxy requests to the Electric server
|
|
@@ -182,153 +195,167 @@ type SyncMetadata = {
|
|
|
182
195
|
handle: string
|
|
183
196
|
}
|
|
184
197
|
|
|
185
|
-
export const makeSyncBackend =
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const headers = yield* HttpClientResponse.schemaHeaders(ResponseHeaders)(resp)
|
|
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}`)
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Electric completes the long-poll request after ~20 seconds with a 204 status
|
|
238
|
-
// In this case we just retry where we left off
|
|
239
|
-
if (resp.status === 204) {
|
|
240
|
-
return Option.some([Chunk.empty(), Option.some(nextHandle)] as const)
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem), {
|
|
244
|
-
onExcessProperty: 'preserve',
|
|
245
|
-
})(resp)
|
|
246
|
-
|
|
247
|
-
const items = body
|
|
248
|
-
.filter((item) => item.value !== undefined && (item.headers as any).operation === 'insert')
|
|
249
|
-
.map((item) => ({
|
|
250
|
-
metadata: Option.some({ offset: nextHandle.offset!, handle: nextHandle.handle }),
|
|
251
|
-
mutationEventEncoded: item.value! as MutationEvent.AnyEncodedGlobal,
|
|
252
|
-
}))
|
|
253
|
-
|
|
254
|
-
// // TODO implement proper `remaining` handling
|
|
255
|
-
// remaining: 0,
|
|
198
|
+
export const makeSyncBackend =
|
|
199
|
+
({ endpoint }: SyncBackendOptions): SyncBackendConstructor<SyncMetadata> =>
|
|
200
|
+
({ storeId, payload }) =>
|
|
201
|
+
Effect.gen(function* () {
|
|
202
|
+
const isConnected = yield* SubscriptionRef.make(true)
|
|
203
|
+
const pullEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.pull
|
|
204
|
+
const pushEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.push
|
|
205
|
+
|
|
206
|
+
// TODO check whether we still need this
|
|
207
|
+
const pendingPushDeferredMap = new Map<EventId.GlobalEventId, Deferred.Deferred<SyncMetadata>>()
|
|
208
|
+
|
|
209
|
+
const pull = (
|
|
210
|
+
handle: Option.Option<SyncMetadata>,
|
|
211
|
+
): Effect.Effect<
|
|
212
|
+
Option.Option<
|
|
213
|
+
readonly [
|
|
214
|
+
Chunk.Chunk<{
|
|
215
|
+
metadata: Option.Option<SyncMetadata>
|
|
216
|
+
mutationEventEncoded: MutationEvent.AnyEncodedGlobal
|
|
217
|
+
}>,
|
|
218
|
+
Option.Option<SyncMetadata>,
|
|
219
|
+
]
|
|
220
|
+
>,
|
|
221
|
+
InvalidPullError | IsOfflineError,
|
|
222
|
+
HttpClient.HttpClient
|
|
223
|
+
> =>
|
|
224
|
+
Effect.gen(function* () {
|
|
225
|
+
const argsJson = yield* Schema.encode(Schema.parseJson(ApiSchema.PullPayload))(
|
|
226
|
+
ApiSchema.PullPayload.make({ storeId, handle }),
|
|
227
|
+
)
|
|
228
|
+
const url = `${pullEndpoint}?args=${argsJson}`
|
|
256
229
|
|
|
257
|
-
|
|
258
|
-
// return Option.none()
|
|
259
|
-
// }
|
|
230
|
+
const resp = yield* HttpClient.get(url)
|
|
260
231
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
232
|
+
if (resp.status === 401) {
|
|
233
|
+
return yield* InvalidPullError.make({
|
|
234
|
+
message: `Unauthorized (401): Couldn't connect to ElectricSQL`,
|
|
235
|
+
})
|
|
265
236
|
}
|
|
266
|
-
}
|
|
267
237
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return {
|
|
275
|
-
pull: (args) =>
|
|
276
|
-
Stream.unfoldChunkEffect(
|
|
277
|
-
args.pipe(
|
|
278
|
-
Option.map((_) => _.metadata),
|
|
279
|
-
Option.flatten,
|
|
280
|
-
),
|
|
281
|
-
(metadataOption) => pull(metadataOption),
|
|
282
|
-
).pipe(
|
|
283
|
-
Stream.chunks,
|
|
284
|
-
Stream.map((chunk) => ({ batch: [...chunk], remaining: 0 })),
|
|
285
|
-
),
|
|
238
|
+
const headers = yield* HttpClientResponse.schemaHeaders(ResponseHeaders)(resp)
|
|
239
|
+
const nextHandle = {
|
|
240
|
+
offset: headers['electric-offset'],
|
|
241
|
+
handle: headers['electric-handle'],
|
|
242
|
+
}
|
|
286
243
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
244
|
+
// TODO handle case where Electric shape is not found for a given handle
|
|
245
|
+
// https://electric-sql.com/openapi.html#/paths/~1v1~1shape/get
|
|
246
|
+
// {
|
|
247
|
+
// "message": "The shape associated with this shape_handle and offset was not found. Resync to fetch the latest shape",
|
|
248
|
+
// "shape_handle": "2494_84241",
|
|
249
|
+
// "offset": "-1"
|
|
250
|
+
// }
|
|
251
|
+
if (resp.status === 409) {
|
|
252
|
+
// TODO: implementation plan:
|
|
253
|
+
// start pulling events from scratch with the new handle and ignore the "old events"
|
|
254
|
+
// until we found a new event, then, continue with the new handle
|
|
255
|
+
return notYetImplemented(`Electric shape not found for handle ${nextHandle.handle}`)
|
|
294
256
|
}
|
|
295
257
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
|
|
302
|
-
Effect.scoped,
|
|
303
|
-
Effect.mapError((cause) =>
|
|
304
|
-
InvalidPushError.make({ reason: { _tag: 'Unexpected', message: cause.toString() } }),
|
|
305
|
-
),
|
|
306
|
-
)
|
|
258
|
+
// Electric completes the long-poll request after ~20 seconds with a 204 status
|
|
259
|
+
// In this case we just retry where we left off
|
|
260
|
+
if (resp.status === 204) {
|
|
261
|
+
return Option.some([Chunk.empty(), Option.some(nextHandle)] as const)
|
|
262
|
+
}
|
|
307
263
|
|
|
308
|
-
|
|
309
|
-
|
|
264
|
+
const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem), {
|
|
265
|
+
onExcessProperty: 'preserve',
|
|
266
|
+
})(resp)
|
|
267
|
+
|
|
268
|
+
const items = body
|
|
269
|
+
.filter((item) => item.value !== undefined && (item.headers as any).operation === 'insert')
|
|
270
|
+
.map((item) => ({
|
|
271
|
+
metadata: Option.some({ offset: nextHandle.offset!, handle: nextHandle.handle }),
|
|
272
|
+
mutationEventEncoded: item.value! as MutationEvent.AnyEncodedGlobal,
|
|
273
|
+
}))
|
|
274
|
+
|
|
275
|
+
// // TODO implement proper `remaining` handling
|
|
276
|
+
// remaining: 0,
|
|
277
|
+
|
|
278
|
+
// if (listenForNew === false && items.length === 0) {
|
|
279
|
+
// return Option.none()
|
|
280
|
+
// }
|
|
281
|
+
|
|
282
|
+
for (const item of items) {
|
|
283
|
+
const deferred = pendingPushDeferredMap.get(item.mutationEventEncoded.id)
|
|
284
|
+
if (deferred !== undefined) {
|
|
285
|
+
yield* Deferred.succeed(deferred, Option.getOrThrow(item.metadata))
|
|
286
|
+
}
|
|
310
287
|
}
|
|
311
288
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
289
|
+
return Option.some([Chunk.fromIterable(items), Option.some(nextHandle)] as const)
|
|
290
|
+
}).pipe(
|
|
291
|
+
Effect.scoped,
|
|
292
|
+
Effect.mapError((cause) => InvalidPullError.make({ message: cause.toString() })),
|
|
293
|
+
)
|
|
315
294
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
295
|
+
return {
|
|
296
|
+
pull: (args) =>
|
|
297
|
+
Stream.unfoldChunkEffect(
|
|
298
|
+
args.pipe(
|
|
299
|
+
Option.map((_) => _.metadata),
|
|
300
|
+
Option.flatten,
|
|
301
|
+
),
|
|
302
|
+
(metadataOption) => pull(metadataOption),
|
|
303
|
+
).pipe(
|
|
304
|
+
Stream.chunks,
|
|
305
|
+
Stream.map((chunk) => ({ batch: [...chunk], remaining: 0 })),
|
|
306
|
+
),
|
|
319
307
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
308
|
+
push: (batch) =>
|
|
309
|
+
Effect.gen(function* () {
|
|
310
|
+
const deferreds: Deferred.Deferred<SyncMetadata>[] = []
|
|
311
|
+
for (const mutationEventEncoded of batch) {
|
|
312
|
+
const deferred = yield* Deferred.make<SyncMetadata>()
|
|
313
|
+
pendingPushDeferredMap.set(mutationEventEncoded.id, deferred)
|
|
314
|
+
deferreds.push(deferred)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const resp = yield* HttpClientRequest.schemaBodyJson(ApiSchema.PushPayload)(
|
|
318
|
+
HttpClientRequest.post(pushEndpoint),
|
|
319
|
+
ApiSchema.PushPayload.make({ storeId, batch }),
|
|
320
|
+
).pipe(
|
|
321
|
+
Effect.andThen(HttpClient.execute),
|
|
322
|
+
Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
|
|
323
|
+
Effect.scoped,
|
|
324
|
+
Effect.mapError((cause) =>
|
|
325
|
+
InvalidPushError.make({ reason: { _tag: 'Unexpected', message: cause.toString() } }),
|
|
326
|
+
),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
if (!resp.success) {
|
|
330
|
+
yield* InvalidPushError.make({ reason: { _tag: 'Unexpected', message: 'Push failed' } })
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const metadata = yield* Effect.all(deferreds, { concurrency: 'unbounded' }).pipe(
|
|
334
|
+
Effect.map((_) => _.map(Option.some)),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
for (const mutationEventEncoded of batch) {
|
|
338
|
+
pendingPushDeferredMap.delete(mutationEventEncoded.id)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return { metadata }
|
|
342
|
+
}),
|
|
343
|
+
isConnected,
|
|
344
|
+
metadata: {
|
|
345
|
+
name: '@livestore/sync-electric',
|
|
346
|
+
description: 'LiveStore sync backend implementation using ElectricSQL',
|
|
347
|
+
protocol: 'http',
|
|
348
|
+
endpoint,
|
|
349
|
+
},
|
|
350
|
+
} satisfies SyncBackend<SyncMetadata>
|
|
351
|
+
})
|
|
325
352
|
|
|
326
353
|
/**
|
|
327
354
|
* Needs to be bumped when the storage format changes (e.g. mutationLogTable schema changes)
|
|
328
355
|
*
|
|
329
356
|
* Changing this version number will lead to a "soft reset".
|
|
330
357
|
*/
|
|
331
|
-
export const PERSISTENCE_FORMAT_VERSION =
|
|
358
|
+
export const PERSISTENCE_FORMAT_VERSION = 4
|
|
332
359
|
|
|
333
360
|
export const toTableName = (storeId: string) => {
|
|
334
361
|
const escapedStoreId = storeId.replaceAll(/[^a-zA-Z0-9_]/g, '_')
|
package/tmp/pack.tgz
CHANGED
|
Binary file
|