@livestore/sync-electric 0.3.0-dev.8 → 0.3.0
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 +47 -0
- package/dist/api-schema.d.ts.map +1 -0
- package/dist/api-schema.js +16 -0
- package/dist/api-schema.js.map +1 -0
- package/dist/index.d.ts +49 -39
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +166 -66
- package/dist/index.js.map +1 -1
- package/package.json +9 -3
- package/src/api-schema.ts +20 -0
- package/src/index.ts +297 -163
- package/tsconfig.json +0 -10
package/src/index.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import type { SyncBackend } from '@livestore/common'
|
|
2
|
-
import { InvalidPullError, InvalidPushError } from '@livestore/common'
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import type { Scope } from '@livestore/utils/effect'
|
|
1
|
+
import type { IsOfflineError, SyncBackend, SyncBackendConstructor } from '@livestore/common'
|
|
2
|
+
import { InvalidPullError, InvalidPushError, UnexpectedError } from '@livestore/common'
|
|
3
|
+
import { LiveStoreEvent } from '@livestore/common/schema'
|
|
4
|
+
import { notYetImplemented, shouldNeverHappen } from '@livestore/utils'
|
|
6
5
|
import {
|
|
7
6
|
Chunk,
|
|
8
|
-
Deferred,
|
|
9
7
|
Effect,
|
|
10
8
|
HttpClient,
|
|
11
9
|
HttpClientRequest,
|
|
@@ -16,200 +14,336 @@ import {
|
|
|
16
14
|
SubscriptionRef,
|
|
17
15
|
} from '@livestore/utils/effect'
|
|
18
16
|
|
|
17
|
+
import * as ApiSchema from './api-schema.js'
|
|
18
|
+
|
|
19
|
+
export * as ApiSchema from './api-schema.js'
|
|
20
|
+
|
|
19
21
|
/*
|
|
20
22
|
Example data:
|
|
21
23
|
|
|
22
|
-
[
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
[
|
|
25
|
+
{
|
|
26
|
+
"value": {
|
|
27
|
+
"args": "{\"id\": \"127c3df4-0855-4587-ae75-14463f4a3aa0\", \"text\": \"1\"}",
|
|
28
|
+
"clientId": "S_YOa",
|
|
29
|
+
"id": "0",
|
|
30
|
+
"name": "todoCreated",
|
|
31
|
+
"parentSeqNum": "-1"
|
|
32
|
+
},
|
|
33
|
+
"key": "\"public\".\"events_9069baf0_b3e6_42f7_980f_188416eab3fx3\"/\"0\"",
|
|
34
|
+
"headers": {
|
|
35
|
+
"last": true,
|
|
36
|
+
"relation": [
|
|
37
|
+
"public",
|
|
38
|
+
"events_9069baf0_b3e6_42f7_980f_188416eab3fx3"
|
|
39
|
+
],
|
|
40
|
+
"operation": "insert",
|
|
41
|
+
"lsn": 27294160,
|
|
42
|
+
"op_position": 0,
|
|
43
|
+
"txids": [
|
|
44
|
+
753
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"headers": {
|
|
50
|
+
"control": "up-to-date",
|
|
51
|
+
"global_last_seen_lsn": 27294160
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
|
|
25
56
|
|
|
26
57
|
Also see: https://github.com/electric-sql/electric/blob/main/packages/typescript-client/src/client.ts
|
|
27
58
|
|
|
28
59
|
*/
|
|
29
60
|
|
|
61
|
+
const LiveStoreEventGlobalFromStringRecord = Schema.Struct({
|
|
62
|
+
seqNum: Schema.NumberFromString,
|
|
63
|
+
parentSeqNum: Schema.NumberFromString,
|
|
64
|
+
name: Schema.String,
|
|
65
|
+
args: Schema.parseJson(Schema.Any),
|
|
66
|
+
clientId: Schema.String,
|
|
67
|
+
sessionId: Schema.String,
|
|
68
|
+
}).pipe(
|
|
69
|
+
Schema.transform(LiveStoreEvent.AnyEncodedGlobal, {
|
|
70
|
+
decode: (_) => _,
|
|
71
|
+
encode: (_) => _,
|
|
72
|
+
}),
|
|
73
|
+
)
|
|
74
|
+
|
|
30
75
|
const ResponseItem = Schema.Struct({
|
|
31
|
-
/** Postgres path (e.g. "public.
|
|
76
|
+
/** Postgres path (e.g. `"public"."events_9069baf0_b3e6_42f7_980f_188416eab3fx3"/"0"`) */
|
|
32
77
|
key: Schema.optional(Schema.String),
|
|
33
|
-
value: Schema.optional(
|
|
34
|
-
headers: Schema.
|
|
35
|
-
|
|
78
|
+
value: Schema.optional(LiveStoreEventGlobalFromStringRecord),
|
|
79
|
+
headers: Schema.Union(
|
|
80
|
+
Schema.Struct({
|
|
81
|
+
operation: Schema.Union(Schema.Literal('insert'), Schema.Literal('update'), Schema.Literal('delete')),
|
|
82
|
+
relation: Schema.Array(Schema.String),
|
|
83
|
+
}),
|
|
84
|
+
Schema.Struct({
|
|
85
|
+
control: Schema.String,
|
|
86
|
+
}),
|
|
87
|
+
),
|
|
36
88
|
})
|
|
37
89
|
|
|
38
90
|
const ResponseHeaders = Schema.Struct({
|
|
39
|
-
'
|
|
40
|
-
// '
|
|
91
|
+
'electric-handle': Schema.String,
|
|
92
|
+
// 'electric-schema': Schema.parseJson(Schema.Any),
|
|
41
93
|
/** e.g. 26799576_0 */
|
|
42
|
-
'
|
|
94
|
+
'electric-offset': Schema.String,
|
|
43
95
|
})
|
|
44
96
|
|
|
45
97
|
export const syncBackend = {} as any
|
|
46
98
|
|
|
47
|
-
export const ApiPushEventPayload = Schema.TaggedStruct('sync-electric.PushEvent', {
|
|
48
|
-
roomId: Schema.String,
|
|
49
|
-
batch: Schema.Array(MutationEvent.AnyEncodedGlobal),
|
|
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
99
|
export const syncBackendOptions = <TOptions extends SyncBackendOptions>(options: TOptions) => options
|
|
59
100
|
|
|
60
|
-
|
|
101
|
+
/**
|
|
102
|
+
* This function should be called in a trusted environment (e.g. a proxy server) as it
|
|
103
|
+
* requires access to senstive information (e.g. `apiSecret` / `sourceSecret`).
|
|
104
|
+
*/
|
|
105
|
+
export const makeElectricUrl = ({
|
|
106
|
+
electricHost,
|
|
107
|
+
searchParams: providedSearchParams,
|
|
108
|
+
sourceId,
|
|
109
|
+
sourceSecret,
|
|
110
|
+
apiSecret,
|
|
111
|
+
}: {
|
|
112
|
+
electricHost: string
|
|
61
113
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
114
|
+
* Needed to extract information from the search params which the `@livestore/sync-electric`
|
|
115
|
+
* client implementation automatically adds:
|
|
116
|
+
* - `handle`: the ElectricSQL handle
|
|
117
|
+
* - `storeId`: the Livestore storeId
|
|
65
118
|
*/
|
|
66
|
-
|
|
67
|
-
|
|
119
|
+
searchParams: URLSearchParams
|
|
120
|
+
/** Needed for Electric Cloud */
|
|
121
|
+
sourceId?: string
|
|
122
|
+
/** Needed for Electric Cloud */
|
|
123
|
+
sourceSecret?: string
|
|
124
|
+
/** For self-hosted ElectricSQL */
|
|
125
|
+
apiSecret?: string
|
|
126
|
+
}) => {
|
|
127
|
+
const endpointUrl = `${electricHost}/v1/shape`
|
|
128
|
+
const argsResult = Schema.decodeUnknownEither(Schema.Struct({ args: Schema.parseJson(ApiSchema.PullPayload) }))(
|
|
129
|
+
Object.fromEntries(providedSearchParams.entries()),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if (argsResult._tag === 'Left') {
|
|
133
|
+
return shouldNeverHappen(
|
|
134
|
+
'Invalid search params provided to makeElectricUrl',
|
|
135
|
+
providedSearchParams,
|
|
136
|
+
Object.fromEntries(providedSearchParams.entries()),
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const args = argsResult.right.args
|
|
141
|
+
const tableName = toTableName(args.storeId)
|
|
142
|
+
const searchParams = new URLSearchParams()
|
|
143
|
+
searchParams.set('table', tableName)
|
|
144
|
+
if (sourceId !== undefined) {
|
|
145
|
+
searchParams.set('source_id', sourceId)
|
|
146
|
+
}
|
|
147
|
+
if (sourceSecret !== undefined) {
|
|
148
|
+
searchParams.set('source_secret', sourceSecret)
|
|
149
|
+
}
|
|
150
|
+
if (apiSecret !== undefined) {
|
|
151
|
+
searchParams.set('api_secret', apiSecret)
|
|
152
|
+
}
|
|
153
|
+
if (args.handle._tag === 'None') {
|
|
154
|
+
searchParams.set('offset', '-1')
|
|
155
|
+
} else {
|
|
156
|
+
searchParams.set('offset', args.handle.value.offset)
|
|
157
|
+
searchParams.set('handle', args.handle.value.handle)
|
|
158
|
+
searchParams.set('live', 'true')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const payload = args.payload
|
|
162
|
+
|
|
163
|
+
const url = `${endpointUrl}?${searchParams.toString()}`
|
|
164
|
+
|
|
165
|
+
return { url, storeId: args.storeId, needsInit: args.handle._tag === 'None', payload }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface SyncBackendOptions {
|
|
68
169
|
/**
|
|
69
|
-
* The
|
|
170
|
+
* The endpoint to pull/push events. Pull is a `GET` request, push is a `POST` request.
|
|
171
|
+
* Usually this endpoint is part of your API layer to proxy requests to the Electric server
|
|
172
|
+
* e.g. to implement auth, rate limiting, etc.
|
|
70
173
|
*
|
|
71
|
-
* @example "/api/
|
|
72
|
-
* @example "
|
|
174
|
+
* @example "/api/electric"
|
|
175
|
+
* @example { push: "/api/push-event", pull: "/api/pull-event" }
|
|
73
176
|
*/
|
|
74
|
-
|
|
177
|
+
endpoint:
|
|
178
|
+
| string
|
|
179
|
+
| {
|
|
180
|
+
push: string
|
|
181
|
+
pull: string
|
|
182
|
+
}
|
|
75
183
|
}
|
|
76
184
|
|
|
185
|
+
export const SyncMetadata = Schema.Struct({
|
|
186
|
+
offset: Schema.String,
|
|
187
|
+
// TODO move this into some kind of "global" sync metadata as it's the same for each event
|
|
188
|
+
handle: Schema.String,
|
|
189
|
+
})
|
|
190
|
+
|
|
77
191
|
type SyncMetadata = {
|
|
78
192
|
offset: string
|
|
79
193
|
// TODO move this into some kind of "global" sync metadata as it's the same for each event
|
|
80
|
-
|
|
194
|
+
handle: string
|
|
81
195
|
}
|
|
82
196
|
|
|
83
|
-
export const makeSyncBackend =
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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 }),
|
|
115
|
-
)
|
|
116
|
-
|
|
117
|
-
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'],
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Electric completes the long-poll request after ~20 seconds with a 204 status
|
|
124
|
-
// In this case we just retry where we left off
|
|
125
|
-
if (resp.status === 204) {
|
|
126
|
-
return Option.some([Chunk.empty(), Option.some(nextCursor)] as const)
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem))(resp)
|
|
130
|
-
|
|
131
|
-
const items = body
|
|
132
|
-
.filter((item) => item.value !== undefined)
|
|
133
|
-
.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
|
-
},
|
|
141
|
-
}))
|
|
142
|
-
|
|
143
|
-
// // TODO implement proper `remaining` handling
|
|
144
|
-
// remaining: 0,
|
|
145
|
-
|
|
146
|
-
// if (listenForNew === false && items.length === 0) {
|
|
147
|
-
// return Option.none()
|
|
148
|
-
// }
|
|
149
|
-
|
|
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))
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return Option.some([newItems, Option.some(nextCursor)] as const)
|
|
160
|
-
}).pipe(
|
|
161
|
-
Effect.scoped,
|
|
162
|
-
Effect.mapError((cause) => InvalidPullError.make({ message: cause.toString() })),
|
|
163
|
-
)
|
|
164
|
-
|
|
165
|
-
return {
|
|
166
|
-
pull: (args) =>
|
|
167
|
-
Stream.unfoldChunkEffect(
|
|
168
|
-
args.pipe(
|
|
169
|
-
Option.map((_) => _.metadata),
|
|
170
|
-
Option.flatten,
|
|
171
|
-
),
|
|
172
|
-
(metadataOption) => pull(metadataOption),
|
|
173
|
-
).pipe(
|
|
174
|
-
Stream.chunks,
|
|
175
|
-
Stream.map((chunk) => ({ batch: [...chunk], remaining: 0 })),
|
|
176
|
-
),
|
|
177
|
-
|
|
178
|
-
push: (batch) =>
|
|
197
|
+
export const makeSyncBackend =
|
|
198
|
+
({ endpoint }: SyncBackendOptions): SyncBackendConstructor<SyncMetadata> =>
|
|
199
|
+
({ storeId, payload }) =>
|
|
200
|
+
Effect.gen(function* () {
|
|
201
|
+
const isConnected = yield* SubscriptionRef.make(true)
|
|
202
|
+
const pullEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.pull
|
|
203
|
+
const pushEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.push
|
|
204
|
+
|
|
205
|
+
const pull = (
|
|
206
|
+
handle: Option.Option<SyncMetadata>,
|
|
207
|
+
): Effect.Effect<
|
|
208
|
+
Option.Option<
|
|
209
|
+
readonly [
|
|
210
|
+
Chunk.Chunk<{
|
|
211
|
+
metadata: Option.Option<SyncMetadata>
|
|
212
|
+
eventEncoded: LiveStoreEvent.AnyEncodedGlobal
|
|
213
|
+
}>,
|
|
214
|
+
Option.Option<SyncMetadata>,
|
|
215
|
+
]
|
|
216
|
+
>,
|
|
217
|
+
InvalidPullError | IsOfflineError,
|
|
218
|
+
HttpClient.HttpClient
|
|
219
|
+
> =>
|
|
179
220
|
Effect.gen(function* () {
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
221
|
+
const argsJson = yield* Schema.encode(Schema.parseJson(ApiSchema.PullPayload))(
|
|
222
|
+
ApiSchema.PullPayload.make({ storeId, handle, payload }),
|
|
223
|
+
)
|
|
224
|
+
const url = `${pullEndpoint}?args=${argsJson}`
|
|
225
|
+
|
|
226
|
+
const resp = yield* HttpClient.get(url)
|
|
227
|
+
|
|
228
|
+
if (resp.status === 401) {
|
|
229
|
+
const body = yield* resp.text.pipe(Effect.catchAll(() => Effect.succeed('-')))
|
|
230
|
+
return yield* InvalidPullError.make({
|
|
231
|
+
message: `Unauthorized (401): Couldn't connect to ElectricSQL: ${body}`,
|
|
232
|
+
})
|
|
233
|
+
} else if (resp.status === 409) {
|
|
234
|
+
// https://electric-sql.com/openapi.html#/paths/~1v1~1shape/get
|
|
235
|
+
// {
|
|
236
|
+
// "message": "The shape associated with this shape_handle and offset was not found. Resync to fetch the latest shape",
|
|
237
|
+
// "shape_handle": "2494_84241",
|
|
238
|
+
// "offset": "-1"
|
|
239
|
+
// }
|
|
240
|
+
|
|
241
|
+
// TODO: implementation plan:
|
|
242
|
+
// start pulling events from scratch with the new handle and ignore the "old events"
|
|
243
|
+
// until we found a new event, then, continue with the new handle
|
|
244
|
+
return notYetImplemented(`Electric shape not found`)
|
|
245
|
+
} else if (resp.status < 200 || resp.status >= 300) {
|
|
246
|
+
return yield* InvalidPullError.make({
|
|
247
|
+
message: `Unexpected status code: ${resp.status}`,
|
|
248
|
+
})
|
|
185
249
|
}
|
|
186
250
|
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
|
|
193
|
-
Effect.scoped,
|
|
194
|
-
Effect.mapError((cause) =>
|
|
195
|
-
InvalidPushError.make({ reason: { _tag: 'Unexpected', message: cause.toString() } }),
|
|
196
|
-
),
|
|
197
|
-
)
|
|
251
|
+
const headers = yield* HttpClientResponse.schemaHeaders(ResponseHeaders)(resp)
|
|
252
|
+
const nextHandle = {
|
|
253
|
+
offset: headers['electric-offset'],
|
|
254
|
+
handle: headers['electric-handle'],
|
|
255
|
+
}
|
|
198
256
|
|
|
199
|
-
|
|
200
|
-
|
|
257
|
+
// Electric completes the long-poll request after ~20 seconds with a 204 status
|
|
258
|
+
// In this case we just retry where we left off
|
|
259
|
+
if (resp.status === 204) {
|
|
260
|
+
return Option.some([Chunk.empty(), Option.some(nextHandle)] as const)
|
|
201
261
|
}
|
|
202
262
|
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
)
|
|
263
|
+
const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem), {
|
|
264
|
+
onExcessProperty: 'preserve',
|
|
265
|
+
})(resp)
|
|
266
|
+
|
|
267
|
+
const items = body
|
|
268
|
+
.filter((item) => item.value !== undefined && (item.headers as any).operation === 'insert')
|
|
269
|
+
.map((item) => ({
|
|
270
|
+
metadata: Option.some({ offset: nextHandle.offset!, handle: nextHandle.handle }),
|
|
271
|
+
eventEncoded: item.value! as LiveStoreEvent.AnyEncodedGlobal,
|
|
272
|
+
}))
|
|
273
|
+
|
|
274
|
+
// // TODO implement proper `remaining` handling
|
|
275
|
+
// remaining: 0,
|
|
276
|
+
|
|
277
|
+
// if (listenForNew === false && items.length === 0) {
|
|
278
|
+
// return Option.none()
|
|
279
|
+
// }
|
|
280
|
+
|
|
281
|
+
return Option.some([Chunk.fromIterable(items), Option.some(nextHandle)] as const)
|
|
282
|
+
}).pipe(
|
|
283
|
+
Effect.scoped,
|
|
284
|
+
Effect.mapError((cause) =>
|
|
285
|
+
cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ message: cause.toString() }),
|
|
286
|
+
),
|
|
287
|
+
)
|
|
206
288
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
289
|
+
const pullEndpointHasSameOrigin =
|
|
290
|
+
pullEndpoint.startsWith('/') ||
|
|
291
|
+
(globalThis.location !== undefined && globalThis.location.origin === new URL(pullEndpoint).origin)
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
// If the pull endpoint has the same origin as the current page, we can assume that we already have a connection
|
|
295
|
+
// otherwise we send a HEAD request to speed up the connection process
|
|
296
|
+
connect: pullEndpointHasSameOrigin
|
|
297
|
+
? Effect.void
|
|
298
|
+
: HttpClient.head(pullEndpoint).pipe(UnexpectedError.mapToUnexpectedError),
|
|
299
|
+
pull: (args) =>
|
|
300
|
+
Stream.unfoldChunkEffect(
|
|
301
|
+
args.pipe(
|
|
302
|
+
Option.map((_) => _.metadata),
|
|
303
|
+
Option.flatten,
|
|
304
|
+
),
|
|
305
|
+
(metadataOption) => pull(metadataOption),
|
|
306
|
+
).pipe(
|
|
307
|
+
Stream.chunks,
|
|
308
|
+
Stream.map((chunk) => ({ batch: [...chunk], remaining: 0 })),
|
|
309
|
+
),
|
|
210
310
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
311
|
+
push: (batch) =>
|
|
312
|
+
Effect.gen(function* () {
|
|
313
|
+
const resp = yield* HttpClientRequest.schemaBodyJson(ApiSchema.PushPayload)(
|
|
314
|
+
HttpClientRequest.post(pushEndpoint),
|
|
315
|
+
ApiSchema.PushPayload.make({ storeId, batch }),
|
|
316
|
+
).pipe(
|
|
317
|
+
Effect.andThen(HttpClient.execute),
|
|
318
|
+
Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
|
|
319
|
+
Effect.scoped,
|
|
320
|
+
Effect.mapError((cause) =>
|
|
321
|
+
InvalidPushError.make({ reason: { _tag: 'Unexpected', message: cause.toString() } }),
|
|
322
|
+
),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
if (!resp.success) {
|
|
326
|
+
yield* InvalidPushError.make({ reason: { _tag: 'Unexpected', message: 'Push failed' } })
|
|
327
|
+
}
|
|
328
|
+
}),
|
|
329
|
+
isConnected,
|
|
330
|
+
metadata: {
|
|
331
|
+
name: '@livestore/sync-electric',
|
|
332
|
+
description: 'LiveStore sync backend implementation using ElectricSQL',
|
|
333
|
+
protocol: 'http',
|
|
334
|
+
endpoint,
|
|
335
|
+
},
|
|
336
|
+
} satisfies SyncBackend<SyncMetadata>
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Needs to be bumped when the storage format changes (e.g. eventlogTable schema changes)
|
|
341
|
+
*
|
|
342
|
+
* Changing this version number will lead to a "soft reset".
|
|
343
|
+
*/
|
|
344
|
+
export const PERSISTENCE_FORMAT_VERSION = 6
|
|
345
|
+
|
|
346
|
+
export const toTableName = (storeId: string) => {
|
|
347
|
+
const escapedStoreId = storeId.replaceAll(/[^a-zA-Z0-9_]/g, '_')
|
|
348
|
+
return `eventlog_${PERSISTENCE_FORMAT_VERSION}_${escapedStoreId}`
|
|
349
|
+
}
|
package/tsconfig.json
DELETED