@livestore/sync-electric 0.4.0-dev.1 → 0.4.0-dev.10
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 +13 -0
- package/dist/api-schema.d.ts.map +1 -1
- package/dist/api-schema.js +3 -0
- package/dist/api-schema.js.map +1 -1
- package/dist/index.d.ts +30 -40
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +111 -90
- package/dist/index.js.map +1 -1
- package/dist/make-electric-url.d.ts +42 -0
- package/dist/make-electric-url.d.ts.map +1 -0
- package/dist/make-electric-url.js +60 -0
- package/dist/make-electric-url.js.map +1 -0
- package/package.json +3 -3
- package/src/api-schema.ts +4 -0
- package/src/index.ts +179 -160
- package/src/make-electric-url.ts +110 -0
package/src/index.ts
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
InvalidPullError,
|
|
3
|
+
InvalidPushError,
|
|
4
|
+
type IsOfflineError,
|
|
5
|
+
SyncBackend,
|
|
6
|
+
UnexpectedError,
|
|
7
|
+
} from '@livestore/common'
|
|
3
8
|
import { LiveStoreEvent } from '@livestore/common/schema'
|
|
4
|
-
import { notYetImplemented
|
|
9
|
+
import { notYetImplemented } from '@livestore/utils'
|
|
5
10
|
import {
|
|
6
|
-
|
|
11
|
+
type Duration,
|
|
7
12
|
Effect,
|
|
8
13
|
HttpClient,
|
|
9
14
|
HttpClientRequest,
|
|
10
15
|
HttpClientResponse,
|
|
11
16
|
Option,
|
|
17
|
+
ReadonlyArray,
|
|
18
|
+
Schedule,
|
|
12
19
|
Schema,
|
|
13
20
|
Stream,
|
|
14
21
|
SubscriptionRef,
|
|
@@ -16,7 +23,13 @@ import {
|
|
|
16
23
|
|
|
17
24
|
import * as ApiSchema from './api-schema.ts'
|
|
18
25
|
|
|
26
|
+
export class InvalidOperationError extends Schema.TaggedError<InvalidOperationError>()('InvalidOperationError', {
|
|
27
|
+
operation: Schema.Union(Schema.Literal('delete'), Schema.Literal('update')),
|
|
28
|
+
message: Schema.String,
|
|
29
|
+
}) {}
|
|
30
|
+
|
|
19
31
|
export * as ApiSchema from './api-schema.ts'
|
|
32
|
+
export * from './make-electric-url.ts'
|
|
20
33
|
|
|
21
34
|
/*
|
|
22
35
|
Example data:
|
|
@@ -65,27 +78,36 @@ const LiveStoreEventGlobalFromStringRecord = Schema.Struct({
|
|
|
65
78
|
args: Schema.parseJson(Schema.Any),
|
|
66
79
|
clientId: Schema.String,
|
|
67
80
|
sessionId: Schema.String,
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
|
|
81
|
+
})
|
|
82
|
+
.pipe(
|
|
83
|
+
Schema.transform(LiveStoreEvent.AnyEncodedGlobal, {
|
|
84
|
+
decode: (_) => _,
|
|
85
|
+
encode: (_) => _,
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
.annotations({ title: '@livestore/sync-electric:LiveStoreEventGlobalFromStringRecord' })
|
|
89
|
+
|
|
90
|
+
const ResponseItemInsert = Schema.Struct({
|
|
76
91
|
/** Postgres path (e.g. `"public"."events_9069baf0_b3e6_42f7_980f_188416eab3fx3"/"0"`) */
|
|
77
92
|
key: Schema.optional(Schema.String),
|
|
78
|
-
value:
|
|
79
|
-
headers: Schema.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
93
|
+
value: LiveStoreEventGlobalFromStringRecord,
|
|
94
|
+
headers: Schema.Struct({ operation: Schema.Literal('insert'), relation: Schema.Array(Schema.String) }),
|
|
95
|
+
}).annotations({ title: '@livestore/sync-electric:ResponseItemInsert' })
|
|
96
|
+
|
|
97
|
+
const ResponseItemInvalid = Schema.Struct({
|
|
98
|
+
/** Postgres path (e.g. `"public"."events_9069baf0_b3e6_42f7_980f_188416eab3fx3"/"0"`) */
|
|
99
|
+
key: Schema.optional(Schema.String),
|
|
100
|
+
value: Schema.Any,
|
|
101
|
+
headers: Schema.Struct({ operation: Schema.Literal('update', 'delete'), relation: Schema.Array(Schema.String) }),
|
|
102
|
+
}).annotations({ title: '@livestore/sync-electric:ResponseItemInvalid' })
|
|
103
|
+
|
|
104
|
+
const ResponseItemControl = Schema.Struct({
|
|
105
|
+
key: Schema.optional(Schema.String),
|
|
106
|
+
value: Schema.optional(Schema.Any),
|
|
107
|
+
headers: Schema.Struct({ control: Schema.String }),
|
|
108
|
+
}).annotations({ title: '@livestore/sync-electric:ResponseItemControl' })
|
|
109
|
+
|
|
110
|
+
const ResponseItem = Schema.Union(ResponseItemInsert, ResponseItemInvalid, ResponseItemControl)
|
|
89
111
|
|
|
90
112
|
const ResponseHeaders = Schema.Struct({
|
|
91
113
|
'electric-handle': Schema.String,
|
|
@@ -94,77 +116,8 @@ const ResponseHeaders = Schema.Struct({
|
|
|
94
116
|
'electric-offset': Schema.String,
|
|
95
117
|
})
|
|
96
118
|
|
|
97
|
-
export const syncBackend = {} as any
|
|
98
|
-
|
|
99
119
|
export const syncBackendOptions = <TOptions extends SyncBackendOptions>(options: TOptions) => options
|
|
100
120
|
|
|
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
|
|
113
|
-
/**
|
|
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
|
|
118
|
-
*/
|
|
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
121
|
export interface SyncBackendOptions {
|
|
169
122
|
/**
|
|
170
123
|
* The endpoint to pull/push events. Pull is a `GET` request, push is a `POST` request.
|
|
@@ -179,7 +132,25 @@ export interface SyncBackendOptions {
|
|
|
179
132
|
| {
|
|
180
133
|
push: string
|
|
181
134
|
pull: string
|
|
135
|
+
ping: string
|
|
182
136
|
}
|
|
137
|
+
|
|
138
|
+
ping?: {
|
|
139
|
+
/**
|
|
140
|
+
* @default true
|
|
141
|
+
*/
|
|
142
|
+
enabled?: boolean
|
|
143
|
+
/**
|
|
144
|
+
* How long to wait for a ping response before timing out
|
|
145
|
+
* @default 10 seconds
|
|
146
|
+
*/
|
|
147
|
+
requestTimeout?: Duration.DurationInput
|
|
148
|
+
/**
|
|
149
|
+
* How often to send ping requests
|
|
150
|
+
* @default 10 seconds
|
|
151
|
+
*/
|
|
152
|
+
requestInterval?: Duration.DurationInput
|
|
153
|
+
}
|
|
183
154
|
}
|
|
184
155
|
|
|
185
156
|
export const SyncMetadata = Schema.Struct({
|
|
@@ -188,48 +159,53 @@ export const SyncMetadata = Schema.Struct({
|
|
|
188
159
|
handle: Schema.String,
|
|
189
160
|
})
|
|
190
161
|
|
|
191
|
-
type SyncMetadata =
|
|
192
|
-
offset: string
|
|
193
|
-
// TODO move this into some kind of "global" sync metadata as it's the same for each event
|
|
194
|
-
handle: string
|
|
195
|
-
}
|
|
162
|
+
export type SyncMetadata = typeof SyncMetadata.Type
|
|
196
163
|
|
|
197
164
|
export const makeSyncBackend =
|
|
198
|
-
({ endpoint }: SyncBackendOptions): SyncBackendConstructor<SyncMetadata> =>
|
|
165
|
+
({ endpoint, ...options }: SyncBackendOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
|
|
199
166
|
({ storeId, payload }) =>
|
|
200
167
|
Effect.gen(function* () {
|
|
201
|
-
const isConnected = yield* SubscriptionRef.make(
|
|
168
|
+
const isConnected = yield* SubscriptionRef.make(false)
|
|
202
169
|
const pullEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.pull
|
|
203
170
|
const pushEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.push
|
|
171
|
+
const pingEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.ping
|
|
172
|
+
|
|
173
|
+
const httpClient = yield* HttpClient.HttpClient
|
|
204
174
|
|
|
205
|
-
const
|
|
175
|
+
const runPull = (
|
|
206
176
|
handle: Option.Option<SyncMetadata>,
|
|
177
|
+
{ live }: { live: boolean },
|
|
207
178
|
): Effect.Effect<
|
|
208
179
|
Option.Option<
|
|
209
180
|
readonly [
|
|
210
|
-
|
|
181
|
+
/** The batch of events */
|
|
182
|
+
ReadonlyArray<{
|
|
211
183
|
metadata: Option.Option<SyncMetadata>
|
|
212
184
|
eventEncoded: LiveStoreEvent.AnyEncodedGlobal
|
|
213
185
|
}>,
|
|
186
|
+
/** The next handle to use for the next pull */
|
|
214
187
|
Option.Option<SyncMetadata>,
|
|
215
188
|
]
|
|
216
189
|
>,
|
|
217
|
-
InvalidPullError | IsOfflineError
|
|
218
|
-
HttpClient.HttpClient
|
|
190
|
+
InvalidPullError | IsOfflineError
|
|
219
191
|
> =>
|
|
220
192
|
Effect.gen(function* () {
|
|
221
|
-
const argsJson = yield* Schema.encode(
|
|
222
|
-
ApiSchema.PullPayload.make({ storeId, handle, payload }),
|
|
193
|
+
const argsJson = yield* Schema.encode(ApiSchema.ArgsSchema)(
|
|
194
|
+
ApiSchema.PullPayload.make({ storeId, handle, payload, live }),
|
|
223
195
|
)
|
|
224
196
|
const url = `${pullEndpoint}?args=${argsJson}`
|
|
225
197
|
|
|
226
|
-
const resp = yield*
|
|
198
|
+
const resp = yield* httpClient.get(url)
|
|
227
199
|
|
|
228
200
|
if (resp.status === 401) {
|
|
229
201
|
const body = yield* resp.text.pipe(Effect.catchAll(() => Effect.succeed('-')))
|
|
230
202
|
return yield* InvalidPullError.make({
|
|
231
|
-
|
|
203
|
+
cause: new Error(`Unauthorized (401): Couldn't connect to ElectricSQL: ${body}`),
|
|
232
204
|
})
|
|
205
|
+
} else if (resp.status === 400) {
|
|
206
|
+
// Electric returns 400 when table doesn't exist
|
|
207
|
+
// Return empty result for non-existent tables
|
|
208
|
+
return Option.some([[], Option.none()] as const)
|
|
233
209
|
} else if (resp.status === 409) {
|
|
234
210
|
// https://electric-sql.com/openapi.html#/paths/~1v1~1shape/get
|
|
235
211
|
// {
|
|
@@ -243,8 +219,9 @@ export const makeSyncBackend =
|
|
|
243
219
|
// until we found a new event, then, continue with the new handle
|
|
244
220
|
return notYetImplemented(`Electric shape not found`)
|
|
245
221
|
} else if (resp.status < 200 || resp.status >= 300) {
|
|
222
|
+
const body = yield* resp.text
|
|
246
223
|
return yield* InvalidPullError.make({
|
|
247
|
-
|
|
224
|
+
cause: new Error(`Unexpected status code: ${resp.status}: ${body}`),
|
|
248
225
|
})
|
|
249
226
|
}
|
|
250
227
|
|
|
@@ -257,56 +234,105 @@ export const makeSyncBackend =
|
|
|
257
234
|
// Electric completes the long-poll request after ~20 seconds with a 204 status
|
|
258
235
|
// In this case we just retry where we left off
|
|
259
236
|
if (resp.status === 204) {
|
|
260
|
-
return Option.some([
|
|
237
|
+
return Option.some([[], Option.some(nextHandle)] as const)
|
|
261
238
|
}
|
|
262
239
|
|
|
263
|
-
const
|
|
240
|
+
const allItems = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem), {
|
|
264
241
|
onExcessProperty: 'preserve',
|
|
265
242
|
})(resp)
|
|
266
243
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
.
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
244
|
+
// Check for delete/update operations and throw descriptive error
|
|
245
|
+
const invalidOperations = ReadonlyArray.filterMap(allItems, (item) =>
|
|
246
|
+
Schema.is(ResponseItemInvalid)(item) ? Option.some(item.headers.operation) : Option.none(),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if (invalidOperations.length > 0) {
|
|
250
|
+
const operation = invalidOperations[0]!
|
|
251
|
+
return yield* new InvalidOperationError({
|
|
252
|
+
operation,
|
|
253
|
+
message: `ElectricSQL '${operation}' event received. This results from directly mutating the event log. Append a series of events that produce the desired state instead of mutating the event log.`,
|
|
254
|
+
})
|
|
255
|
+
}
|
|
273
256
|
|
|
274
|
-
|
|
275
|
-
|
|
257
|
+
const items = allItems.filter(Schema.is(ResponseItemInsert)).map((item) => ({
|
|
258
|
+
metadata: Option.some({ offset: nextHandle.offset, handle: nextHandle.handle }),
|
|
259
|
+
eventEncoded: item.value as LiveStoreEvent.AnyEncodedGlobal,
|
|
260
|
+
}))
|
|
276
261
|
|
|
277
|
-
|
|
278
|
-
// return Option.none()
|
|
279
|
-
// }
|
|
262
|
+
yield* Effect.annotateCurrentSpan({ itemsCount: items.length, nextHandle })
|
|
280
263
|
|
|
281
|
-
return Option.some([
|
|
264
|
+
return Option.some([items, Option.some(nextHandle)] as const)
|
|
282
265
|
}).pipe(
|
|
283
266
|
Effect.scoped,
|
|
284
|
-
Effect.mapError((cause) =>
|
|
285
|
-
|
|
286
|
-
),
|
|
267
|
+
Effect.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
|
|
268
|
+
Effect.withSpan('electric-provider:runPull', { attributes: { handle, live } }),
|
|
287
269
|
)
|
|
288
270
|
|
|
289
271
|
const pullEndpointHasSameOrigin =
|
|
290
272
|
pullEndpoint.startsWith('/') ||
|
|
291
273
|
(globalThis.location !== undefined && globalThis.location.origin === new URL(pullEndpoint).origin)
|
|
292
274
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
275
|
+
const pingTimeout = options.ping?.requestTimeout ?? 10_000
|
|
276
|
+
|
|
277
|
+
const ping: SyncBackend.SyncBackend<SyncMetadata>['ping'] = Effect.gen(function* () {
|
|
278
|
+
yield* httpClient.pipe(HttpClient.filterStatusOk).head(pingEndpoint)
|
|
279
|
+
|
|
280
|
+
yield* SubscriptionRef.set(isConnected, true)
|
|
281
|
+
}).pipe(
|
|
282
|
+
UnexpectedError.mapToUnexpectedError,
|
|
283
|
+
Effect.timeout(pingTimeout),
|
|
284
|
+
Effect.catchTag('TimeoutException', () => SubscriptionRef.set(isConnected, false)),
|
|
285
|
+
Effect.withSpan('electric-provider:ping'),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
const pingInterval = options.ping?.requestInterval ?? 10_000
|
|
289
|
+
|
|
290
|
+
if (options.ping?.enabled !== false) {
|
|
291
|
+
// Automatically ping the server to keep the connection alive
|
|
292
|
+
yield* ping.pipe(Effect.repeat(Schedule.spaced(pingInterval)), Effect.tapCauseLogPretty, Effect.forkScoped)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// If the pull endpoint has the same origin as the current page, we can assume that we already have a connection
|
|
296
|
+
// otherwise we send a HEAD request to speed up the connection process
|
|
297
|
+
const connect: SyncBackend.SyncBackend<SyncMetadata>['connect'] = pullEndpointHasSameOrigin
|
|
298
|
+
? Effect.void
|
|
299
|
+
: ping.pipe(UnexpectedError.mapToUnexpectedError)
|
|
300
|
+
|
|
301
|
+
return SyncBackend.of({
|
|
302
|
+
connect,
|
|
303
|
+
pull: (cursor, options) => {
|
|
304
|
+
let hasEmittedAtLeastOnce = false
|
|
305
|
+
|
|
306
|
+
return Stream.unfoldEffect(cursor.pipe(Option.flatMap((_) => _.metadata)), (metadataOption) =>
|
|
307
|
+
Effect.gen(function* () {
|
|
308
|
+
const result = yield* runPull(metadataOption, { live: options?.live ?? false })
|
|
309
|
+
if (Option.isNone(result)) return Option.none()
|
|
310
|
+
|
|
311
|
+
const [batch, nextMetadataOption] = result.value
|
|
312
|
+
|
|
313
|
+
// Continue pagination if we have data
|
|
314
|
+
if (batch.length > 0) {
|
|
315
|
+
hasEmittedAtLeastOnce = true
|
|
316
|
+
return Option.some([{ batch, hasMore: true }, nextMetadataOption])
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Make sure we emit at least once even if there's no data or we're live-pulling
|
|
320
|
+
if (hasEmittedAtLeastOnce === false || options?.live) {
|
|
321
|
+
hasEmittedAtLeastOnce = true
|
|
322
|
+
return Option.some([{ batch, hasMore: false }, nextMetadataOption])
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Stop on empty batch (when not live)
|
|
326
|
+
return Option.none()
|
|
327
|
+
}),
|
|
306
328
|
).pipe(
|
|
307
|
-
Stream.
|
|
308
|
-
|
|
309
|
-
|
|
329
|
+
Stream.map(({ batch, hasMore }) => ({
|
|
330
|
+
batch,
|
|
331
|
+
pageInfo: hasMore ? SyncBackend.pageInfoMoreUnknown : SyncBackend.pageInfoNoMore,
|
|
332
|
+
})),
|
|
333
|
+
Stream.withSpan('electric-provider:pull'),
|
|
334
|
+
)
|
|
335
|
+
},
|
|
310
336
|
|
|
311
337
|
push: (batch) =>
|
|
312
338
|
Effect.gen(function* () {
|
|
@@ -314,18 +340,17 @@ export const makeSyncBackend =
|
|
|
314
340
|
HttpClientRequest.post(pushEndpoint),
|
|
315
341
|
ApiSchema.PushPayload.make({ storeId, batch }),
|
|
316
342
|
).pipe(
|
|
317
|
-
Effect.andThen(HttpClient.execute),
|
|
343
|
+
Effect.andThen(httpClient.pipe(HttpClient.filterStatusOk).execute),
|
|
318
344
|
Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
|
|
319
345
|
Effect.scoped,
|
|
320
|
-
Effect.mapError((cause) =>
|
|
321
|
-
InvalidPushError.make({ reason: { _tag: 'Unexpected', message: cause.toString() } }),
|
|
322
|
-
),
|
|
346
|
+
Effect.mapError((cause) => InvalidPushError.make({ cause: UnexpectedError.make({ cause }) })),
|
|
323
347
|
)
|
|
324
348
|
|
|
325
349
|
if (!resp.success) {
|
|
326
|
-
yield* InvalidPushError.make({
|
|
350
|
+
return yield* InvalidPushError.make({ cause: new UnexpectedError({ cause: new Error('Push failed') }) })
|
|
327
351
|
}
|
|
328
|
-
}),
|
|
352
|
+
}).pipe(Effect.withSpan('electric-provider:push')),
|
|
353
|
+
ping,
|
|
329
354
|
isConnected,
|
|
330
355
|
metadata: {
|
|
331
356
|
name: '@livestore/sync-electric',
|
|
@@ -333,17 +358,11 @@ export const makeSyncBackend =
|
|
|
333
358
|
protocol: 'http',
|
|
334
359
|
endpoint,
|
|
335
360
|
},
|
|
336
|
-
|
|
361
|
+
supports: {
|
|
362
|
+
// Given Electric is heavily optimized for immutable caching, we can't know the remaining count
|
|
363
|
+
// until we've reached the end of the stream
|
|
364
|
+
pullPageInfoKnown: false,
|
|
365
|
+
pullLive: true,
|
|
366
|
+
},
|
|
367
|
+
})
|
|
337
368
|
})
|
|
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
|
-
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { shouldNeverHappen } from '@livestore/utils'
|
|
2
|
+
import { Hash, Schema } from '@livestore/utils/effect'
|
|
3
|
+
import * as ApiSchema from './api-schema.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* This function should be called in a trusted environment (e.g. a proxy server) as it
|
|
7
|
+
* requires access to senstive information (e.g. `apiSecret` / `sourceSecret`).
|
|
8
|
+
*/
|
|
9
|
+
export const makeElectricUrl = ({
|
|
10
|
+
electricHost,
|
|
11
|
+
searchParams: providedSearchParams,
|
|
12
|
+
sourceId,
|
|
13
|
+
sourceSecret,
|
|
14
|
+
apiSecret,
|
|
15
|
+
}: {
|
|
16
|
+
electricHost: string
|
|
17
|
+
/**
|
|
18
|
+
* Needed to extract information from the search params which the `@livestore/sync-electric`
|
|
19
|
+
* client implementation automatically adds:
|
|
20
|
+
* - `handle`: the ElectricSQL handle
|
|
21
|
+
* - `storeId`: the Livestore storeId
|
|
22
|
+
*/
|
|
23
|
+
searchParams: URLSearchParams
|
|
24
|
+
/** Needed for Electric Cloud */
|
|
25
|
+
sourceId?: string
|
|
26
|
+
/** Needed for Electric Cloud */
|
|
27
|
+
sourceSecret?: string
|
|
28
|
+
/** For self-hosted ElectricSQL */
|
|
29
|
+
apiSecret?: string
|
|
30
|
+
}): {
|
|
31
|
+
/**
|
|
32
|
+
* The URL to the ElectricSQL API endpoint with needed search params.
|
|
33
|
+
*/
|
|
34
|
+
url: string
|
|
35
|
+
/** The Livestore storeId */
|
|
36
|
+
storeId: string
|
|
37
|
+
/**
|
|
38
|
+
* Whether the Postgres table needs to be created.
|
|
39
|
+
*/
|
|
40
|
+
needsInit: boolean
|
|
41
|
+
/** Sync payload provided by the client */
|
|
42
|
+
payload: Schema.JsonValue | undefined
|
|
43
|
+
} => {
|
|
44
|
+
const endpointUrl = `${electricHost}/v1/shape`
|
|
45
|
+
const UrlParamsSchema = Schema.Struct({ args: ApiSchema.ArgsSchema })
|
|
46
|
+
const argsResult = Schema.decodeUnknownEither(UrlParamsSchema)(Object.fromEntries(providedSearchParams.entries()))
|
|
47
|
+
|
|
48
|
+
if (argsResult._tag === 'Left') {
|
|
49
|
+
return shouldNeverHappen(
|
|
50
|
+
'Invalid search params provided to makeElectricUrl',
|
|
51
|
+
providedSearchParams,
|
|
52
|
+
Object.fromEntries(providedSearchParams.entries()),
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const args = argsResult.right.args
|
|
57
|
+
const tableName = toTableName(args.storeId)
|
|
58
|
+
// TODO refactor with Effect URLSearchParams schema
|
|
59
|
+
// https://electric-sql.com/openapi.html
|
|
60
|
+
const searchParams = new URLSearchParams()
|
|
61
|
+
// Electric requires table names with capital letters to be quoted
|
|
62
|
+
// Since our table names include the storeId which may have capitals, we always quote
|
|
63
|
+
searchParams.set('table', `"${tableName}"`)
|
|
64
|
+
if (sourceId !== undefined) {
|
|
65
|
+
searchParams.set('source_id', sourceId)
|
|
66
|
+
}
|
|
67
|
+
if (sourceSecret !== undefined) {
|
|
68
|
+
searchParams.set('source_secret', sourceSecret)
|
|
69
|
+
}
|
|
70
|
+
if (apiSecret !== undefined) {
|
|
71
|
+
searchParams.set('api_secret', apiSecret)
|
|
72
|
+
}
|
|
73
|
+
if (args.handle._tag === 'None') {
|
|
74
|
+
searchParams.set('offset', '-1')
|
|
75
|
+
} else {
|
|
76
|
+
searchParams.set('offset', args.handle.value.offset)
|
|
77
|
+
searchParams.set('handle', args.handle.value.handle)
|
|
78
|
+
searchParams.set('live', args.live ? 'true' : 'false')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const payload = args.payload
|
|
82
|
+
|
|
83
|
+
const url = `${endpointUrl}?${searchParams.toString()}`
|
|
84
|
+
|
|
85
|
+
return { url, storeId: args.storeId, needsInit: args.handle._tag === 'None', payload }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const toTableName = (storeId: string) => {
|
|
89
|
+
const escapedStoreId = storeId.replaceAll(/[^a-zA-Z0-9_]/g, '_')
|
|
90
|
+
const tableName = `eventlog_${PERSISTENCE_FORMAT_VERSION}_${escapedStoreId}`
|
|
91
|
+
|
|
92
|
+
if (tableName.length > 63) {
|
|
93
|
+
const hashedStoreId = Hash.string(storeId)
|
|
94
|
+
|
|
95
|
+
console.warn(
|
|
96
|
+
`Table name is too long: "${tableName}". Postgres table names are limited to 63 characters. Using hashed storeId instead: "${hashedStoreId}".`,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return `eventlog_${PERSISTENCE_FORMAT_VERSION}_hash_${hashedStoreId}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return tableName
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Needs to be bumped when the storage format changes (e.g. eventlogTable schema changes)
|
|
107
|
+
*
|
|
108
|
+
* Changing this version number will lead to a "soft reset".
|
|
109
|
+
*/
|
|
110
|
+
export const PERSISTENCE_FORMAT_VERSION = 6
|