@livestore/sync-electric 0.4.0-dev.3 → 0.4.0-dev.6
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 +21 -35
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +75 -74
- 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 +128 -129
- package/src/make-electric-url.ts +110 -0
package/src/index.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
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
|
+
Schedule,
|
|
12
18
|
Schema,
|
|
13
19
|
Stream,
|
|
14
20
|
SubscriptionRef,
|
|
@@ -17,6 +23,7 @@ import {
|
|
|
17
23
|
import * as ApiSchema from './api-schema.ts'
|
|
18
24
|
|
|
19
25
|
export * as ApiSchema from './api-schema.ts'
|
|
26
|
+
export * from './make-electric-url.ts'
|
|
20
27
|
|
|
21
28
|
/*
|
|
22
29
|
Example data:
|
|
@@ -98,73 +105,6 @@ export const syncBackend = {} as any
|
|
|
98
105
|
|
|
99
106
|
export const syncBackendOptions = <TOptions extends SyncBackendOptions>(options: TOptions) => options
|
|
100
107
|
|
|
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
108
|
export interface SyncBackendOptions {
|
|
169
109
|
/**
|
|
170
110
|
* The endpoint to pull/push events. Pull is a `GET` request, push is a `POST` request.
|
|
@@ -179,7 +119,25 @@ export interface SyncBackendOptions {
|
|
|
179
119
|
| {
|
|
180
120
|
push: string
|
|
181
121
|
pull: string
|
|
122
|
+
ping: string
|
|
182
123
|
}
|
|
124
|
+
|
|
125
|
+
ping?: {
|
|
126
|
+
/**
|
|
127
|
+
* @default true
|
|
128
|
+
*/
|
|
129
|
+
enabled?: boolean
|
|
130
|
+
/**
|
|
131
|
+
* How long to wait for a ping response before timing out
|
|
132
|
+
* @default 10 seconds
|
|
133
|
+
*/
|
|
134
|
+
requestTimeout?: Duration.DurationInput
|
|
135
|
+
/**
|
|
136
|
+
* How often to send ping requests
|
|
137
|
+
* @default 10 seconds
|
|
138
|
+
*/
|
|
139
|
+
requestInterval?: Duration.DurationInput
|
|
140
|
+
}
|
|
183
141
|
}
|
|
184
142
|
|
|
185
143
|
export const SyncMetadata = Schema.Struct({
|
|
@@ -195,41 +153,50 @@ type SyncMetadata = {
|
|
|
195
153
|
}
|
|
196
154
|
|
|
197
155
|
export const makeSyncBackend =
|
|
198
|
-
({ endpoint }: SyncBackendOptions): SyncBackendConstructor<SyncMetadata> =>
|
|
156
|
+
({ endpoint, ...options }: SyncBackendOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
|
|
199
157
|
({ storeId, payload }) =>
|
|
200
158
|
Effect.gen(function* () {
|
|
201
|
-
const isConnected = yield* SubscriptionRef.make(
|
|
159
|
+
const isConnected = yield* SubscriptionRef.make(false)
|
|
202
160
|
const pullEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.pull
|
|
203
161
|
const pushEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.push
|
|
162
|
+
const pingEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.ping
|
|
163
|
+
|
|
164
|
+
const httpClient = yield* HttpClient.HttpClient
|
|
204
165
|
|
|
205
|
-
const
|
|
166
|
+
const runPull = (
|
|
206
167
|
handle: Option.Option<SyncMetadata>,
|
|
168
|
+
{ live }: { live: boolean },
|
|
207
169
|
): Effect.Effect<
|
|
208
170
|
Option.Option<
|
|
209
171
|
readonly [
|
|
210
|
-
|
|
172
|
+
/** The batch of events */
|
|
173
|
+
ReadonlyArray<{
|
|
211
174
|
metadata: Option.Option<SyncMetadata>
|
|
212
175
|
eventEncoded: LiveStoreEvent.AnyEncodedGlobal
|
|
213
176
|
}>,
|
|
177
|
+
/** The next handle to use for the next pull */
|
|
214
178
|
Option.Option<SyncMetadata>,
|
|
215
179
|
]
|
|
216
180
|
>,
|
|
217
|
-
InvalidPullError | IsOfflineError
|
|
218
|
-
HttpClient.HttpClient
|
|
181
|
+
InvalidPullError | IsOfflineError
|
|
219
182
|
> =>
|
|
220
183
|
Effect.gen(function* () {
|
|
221
|
-
const argsJson = yield* Schema.encode(
|
|
222
|
-
ApiSchema.PullPayload.make({ storeId, handle, payload }),
|
|
184
|
+
const argsJson = yield* Schema.encode(ApiSchema.ArgsSchema)(
|
|
185
|
+
ApiSchema.PullPayload.make({ storeId, handle, payload, live }),
|
|
223
186
|
)
|
|
224
187
|
const url = `${pullEndpoint}?args=${argsJson}`
|
|
225
188
|
|
|
226
|
-
const resp = yield*
|
|
189
|
+
const resp = yield* httpClient.get(url)
|
|
227
190
|
|
|
228
191
|
if (resp.status === 401) {
|
|
229
192
|
const body = yield* resp.text.pipe(Effect.catchAll(() => Effect.succeed('-')))
|
|
230
193
|
return yield* InvalidPullError.make({
|
|
231
|
-
|
|
194
|
+
cause: new Error(`Unauthorized (401): Couldn't connect to ElectricSQL: ${body}`),
|
|
232
195
|
})
|
|
196
|
+
} else if (resp.status === 400) {
|
|
197
|
+
// Electric returns 400 when table doesn't exist
|
|
198
|
+
// Return empty result for non-existent tables
|
|
199
|
+
return Option.some([[], Option.none()] as const)
|
|
233
200
|
} else if (resp.status === 409) {
|
|
234
201
|
// https://electric-sql.com/openapi.html#/paths/~1v1~1shape/get
|
|
235
202
|
// {
|
|
@@ -243,8 +210,9 @@ export const makeSyncBackend =
|
|
|
243
210
|
// until we found a new event, then, continue with the new handle
|
|
244
211
|
return notYetImplemented(`Electric shape not found`)
|
|
245
212
|
} else if (resp.status < 200 || resp.status >= 300) {
|
|
213
|
+
const body = yield* resp.text
|
|
246
214
|
return yield* InvalidPullError.make({
|
|
247
|
-
|
|
215
|
+
cause: new Error(`Unexpected status code: ${resp.status}: ${body}`),
|
|
248
216
|
})
|
|
249
217
|
}
|
|
250
218
|
|
|
@@ -257,7 +225,7 @@ export const makeSyncBackend =
|
|
|
257
225
|
// Electric completes the long-poll request after ~20 seconds with a 204 status
|
|
258
226
|
// In this case we just retry where we left off
|
|
259
227
|
if (resp.status === 204) {
|
|
260
|
-
return Option.some([
|
|
228
|
+
return Option.some([[], Option.some(nextHandle)] as const)
|
|
261
229
|
}
|
|
262
230
|
|
|
263
231
|
const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem), {
|
|
@@ -267,46 +235,84 @@ export const makeSyncBackend =
|
|
|
267
235
|
const items = body
|
|
268
236
|
.filter((item) => item.value !== undefined && (item.headers as any).operation === 'insert')
|
|
269
237
|
.map((item) => ({
|
|
270
|
-
metadata: Option.some({ offset: nextHandle.offset
|
|
238
|
+
metadata: Option.some({ offset: nextHandle.offset, handle: nextHandle.handle }),
|
|
271
239
|
eventEncoded: item.value! as LiveStoreEvent.AnyEncodedGlobal,
|
|
272
240
|
}))
|
|
273
241
|
|
|
274
|
-
|
|
275
|
-
// remaining: 0,
|
|
242
|
+
yield* Effect.annotateCurrentSpan({ itemsCount: items.length, nextHandle })
|
|
276
243
|
|
|
277
|
-
|
|
278
|
-
// return Option.none()
|
|
279
|
-
// }
|
|
280
|
-
|
|
281
|
-
return Option.some([Chunk.fromIterable(items), Option.some(nextHandle)] as const)
|
|
244
|
+
return Option.some([items, Option.some(nextHandle)] as const)
|
|
282
245
|
}).pipe(
|
|
283
246
|
Effect.scoped,
|
|
284
|
-
Effect.mapError((cause) =>
|
|
285
|
-
|
|
286
|
-
),
|
|
247
|
+
Effect.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
|
|
248
|
+
Effect.withSpan('electric-provider:runPull', { attributes: { handle, live } }),
|
|
287
249
|
)
|
|
288
250
|
|
|
289
251
|
const pullEndpointHasSameOrigin =
|
|
290
252
|
pullEndpoint.startsWith('/') ||
|
|
291
253
|
(globalThis.location !== undefined && globalThis.location.origin === new URL(pullEndpoint).origin)
|
|
292
254
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
255
|
+
const pingTimeout = options.ping?.requestTimeout ?? 10_000
|
|
256
|
+
|
|
257
|
+
const ping: SyncBackend.SyncBackend<SyncMetadata>['ping'] = Effect.gen(function* () {
|
|
258
|
+
yield* httpClient.pipe(HttpClient.filterStatusOk).head(pingEndpoint)
|
|
259
|
+
|
|
260
|
+
yield* SubscriptionRef.set(isConnected, true)
|
|
261
|
+
}).pipe(
|
|
262
|
+
UnexpectedError.mapToUnexpectedError,
|
|
263
|
+
Effect.timeout(pingTimeout),
|
|
264
|
+
Effect.catchTag('TimeoutException', () => SubscriptionRef.set(isConnected, false)),
|
|
265
|
+
Effect.withSpan('electric-provider:ping'),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
const pingInterval = options.ping?.requestInterval ?? 10_000
|
|
269
|
+
|
|
270
|
+
if (options.ping?.enabled !== false) {
|
|
271
|
+
// Automatically ping the server to keep the connection alive
|
|
272
|
+
yield* ping.pipe(Effect.repeat(Schedule.spaced(pingInterval)), Effect.tapCauseLogPretty, Effect.forkScoped)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// If the pull endpoint has the same origin as the current page, we can assume that we already have a connection
|
|
276
|
+
// otherwise we send a HEAD request to speed up the connection process
|
|
277
|
+
const connect: SyncBackend.SyncBackend<SyncMetadata>['connect'] = pullEndpointHasSameOrigin
|
|
278
|
+
? Effect.void
|
|
279
|
+
: ping.pipe(UnexpectedError.mapToUnexpectedError)
|
|
280
|
+
|
|
281
|
+
return SyncBackend.of({
|
|
282
|
+
connect,
|
|
283
|
+
pull: (cursor, options) => {
|
|
284
|
+
let hasEmittedAtLeastOnce = false
|
|
285
|
+
|
|
286
|
+
return Stream.unfoldEffect(cursor.pipe(Option.flatMap((_) => _.metadata)), (metadataOption) =>
|
|
287
|
+
Effect.gen(function* () {
|
|
288
|
+
const result = yield* runPull(metadataOption, { live: options?.live ?? false })
|
|
289
|
+
if (Option.isNone(result)) return Option.none()
|
|
290
|
+
|
|
291
|
+
const [batch, nextMetadataOption] = result.value
|
|
292
|
+
|
|
293
|
+
// Continue pagination if we have data
|
|
294
|
+
if (batch.length > 0) {
|
|
295
|
+
hasEmittedAtLeastOnce = true
|
|
296
|
+
return Option.some([{ batch, hasMore: true }, nextMetadataOption])
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Make sure we emit at least once even if there's no data or we're live-pulling
|
|
300
|
+
if (hasEmittedAtLeastOnce === false || options?.live) {
|
|
301
|
+
hasEmittedAtLeastOnce = true
|
|
302
|
+
return Option.some([{ batch, hasMore: false }, nextMetadataOption])
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Stop on empty batch (when not live)
|
|
306
|
+
return Option.none()
|
|
307
|
+
}),
|
|
306
308
|
).pipe(
|
|
307
|
-
Stream.
|
|
308
|
-
|
|
309
|
-
|
|
309
|
+
Stream.map(({ batch, hasMore }) => ({
|
|
310
|
+
batch,
|
|
311
|
+
pageInfo: hasMore ? SyncBackend.pageInfoMoreUnknown : SyncBackend.pageInfoNoMore,
|
|
312
|
+
})),
|
|
313
|
+
Stream.withSpan('electric-provider:pull'),
|
|
314
|
+
)
|
|
315
|
+
},
|
|
310
316
|
|
|
311
317
|
push: (batch) =>
|
|
312
318
|
Effect.gen(function* () {
|
|
@@ -314,18 +320,17 @@ export const makeSyncBackend =
|
|
|
314
320
|
HttpClientRequest.post(pushEndpoint),
|
|
315
321
|
ApiSchema.PushPayload.make({ storeId, batch }),
|
|
316
322
|
).pipe(
|
|
317
|
-
Effect.andThen(HttpClient.execute),
|
|
323
|
+
Effect.andThen(httpClient.pipe(HttpClient.filterStatusOk).execute),
|
|
318
324
|
Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
|
|
319
325
|
Effect.scoped,
|
|
320
|
-
Effect.mapError((cause) =>
|
|
321
|
-
InvalidPushError.make({ reason: { _tag: 'Unexpected', message: cause.toString() } }),
|
|
322
|
-
),
|
|
326
|
+
Effect.mapError((cause) => InvalidPushError.make({ cause: UnexpectedError.make({ cause }) })),
|
|
323
327
|
)
|
|
324
328
|
|
|
325
329
|
if (!resp.success) {
|
|
326
|
-
yield* InvalidPushError.make({
|
|
330
|
+
return yield* InvalidPushError.make({ cause: new UnexpectedError({ cause: new Error('Push failed') }) })
|
|
327
331
|
}
|
|
328
|
-
}),
|
|
332
|
+
}).pipe(Effect.withSpan('electric-provider:push')),
|
|
333
|
+
ping,
|
|
329
334
|
isConnected,
|
|
330
335
|
metadata: {
|
|
331
336
|
name: '@livestore/sync-electric',
|
|
@@ -333,17 +338,11 @@ export const makeSyncBackend =
|
|
|
333
338
|
protocol: 'http',
|
|
334
339
|
endpoint,
|
|
335
340
|
},
|
|
336
|
-
|
|
341
|
+
supports: {
|
|
342
|
+
// Given Electric is heavily optimized for immutable caching, we can't know the remaining count
|
|
343
|
+
// until we've reached the end of the stream
|
|
344
|
+
pullPageInfoKnown: false,
|
|
345
|
+
pullLive: true,
|
|
346
|
+
},
|
|
347
|
+
})
|
|
337
348
|
})
|
|
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
|