@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/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 +28 -36
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +121 -47
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/api-schema.ts +19 -0
- package/src/index.ts +167 -77
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
|
-
[
|
|
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
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
110
|
+
if (argsResult._tag === 'Left') {
|
|
111
|
+
return shouldNeverHappen('Invalid search params', searchParams)
|
|
112
|
+
}
|
|
57
113
|
|
|
58
|
-
|
|
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
|
|
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/
|
|
72
|
-
* @example "
|
|
131
|
+
* @example "/api/electric"
|
|
132
|
+
* @example { push: "/api/push-event", pull: "/api/pull-event" }
|
|
73
133
|
*/
|
|
74
|
-
|
|
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
|
-
|
|
151
|
+
handle: string
|
|
81
152
|
}
|
|
82
153
|
|
|
83
154
|
export const makeSyncBackend = ({
|
|
84
|
-
electricHost,
|
|
85
155
|
storeId,
|
|
86
|
-
|
|
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
|
|
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 = (
|
|
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
|
|
104
|
-
|
|
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
|
|
119
|
-
offset: headers['
|
|
120
|
-
|
|
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(
|
|
209
|
+
return Option.some([Chunk.empty(), Option.some(nextHandle)] as const)
|
|
127
210
|
}
|
|
128
211
|
|
|
129
|
-
const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem)
|
|
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:
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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([
|
|
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(
|
|
188
|
-
HttpClientRequest.post(
|
|
189
|
-
|
|
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
|
+
}
|