@livestore/sync-electric 0.4.0-dev.8 → 0.4.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.js +1 -1
- package/dist/api-schema.js.map +1 -1
- package/dist/index.d.ts +42 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +59 -25
- package/dist/index.js.map +1 -1
- package/dist/make-electric-url.d.ts +21 -2
- package/dist/make-electric-url.d.ts.map +1 -1
- package/dist/make-electric-url.js +22 -3
- package/dist/make-electric-url.js.map +1 -1
- package/package.json +68 -12
- package/src/api-schema.ts +1 -1
- package/src/index.ts +75 -47
- package/src/make-electric-url.ts +23 -3
package/src/index.ts
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
InvalidPullError,
|
|
3
|
-
InvalidPushError,
|
|
4
|
-
type IsOfflineError,
|
|
5
|
-
SyncBackend,
|
|
6
|
-
UnexpectedError,
|
|
7
|
-
} from '@livestore/common'
|
|
1
|
+
import { type IsOfflineError, SyncBackend, UnknownError } from '@livestore/common'
|
|
8
2
|
import { LiveStoreEvent } from '@livestore/common/schema'
|
|
9
3
|
import { notYetImplemented } from '@livestore/utils'
|
|
10
4
|
import {
|
|
@@ -23,8 +17,8 @@ import {
|
|
|
23
17
|
|
|
24
18
|
import * as ApiSchema from './api-schema.ts'
|
|
25
19
|
|
|
26
|
-
export class InvalidOperationError extends Schema.TaggedError<InvalidOperationError>()('InvalidOperationError', {
|
|
27
|
-
operation: Schema.
|
|
20
|
+
export class InvalidOperationError extends Schema.TaggedError<InvalidOperationError>('~@livestore/sync-electric/InvalidOperationError')('InvalidOperationError', {
|
|
21
|
+
operation: Schema.Literal('delete', 'update'),
|
|
28
22
|
message: Schema.String,
|
|
29
23
|
}) {}
|
|
30
24
|
|
|
@@ -79,12 +73,7 @@ const LiveStoreEventGlobalFromStringRecord = Schema.Struct({
|
|
|
79
73
|
clientId: Schema.String,
|
|
80
74
|
sessionId: Schema.String,
|
|
81
75
|
})
|
|
82
|
-
.pipe(
|
|
83
|
-
Schema.transform(LiveStoreEvent.AnyEncodedGlobal, {
|
|
84
|
-
decode: (_) => _,
|
|
85
|
-
encode: (_) => _,
|
|
86
|
-
}),
|
|
87
|
-
)
|
|
76
|
+
.pipe(Schema.compose(LiveStoreEvent.Global.Encoded))
|
|
88
77
|
.annotations({ title: '@livestore/sync-electric:LiveStoreEventGlobalFromStringRecord' })
|
|
89
78
|
|
|
90
79
|
const ResponseItemInsert = Schema.Struct({
|
|
@@ -161,6 +150,47 @@ export const SyncMetadata = Schema.Struct({
|
|
|
161
150
|
|
|
162
151
|
export type SyncMetadata = typeof SyncMetadata.Type
|
|
163
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Creates a sync backend that uses ElectricSQL for real-time event synchronization.
|
|
155
|
+
*
|
|
156
|
+
* ElectricSQL enables real-time sync by streaming PostgreSQL changes to clients.
|
|
157
|
+
* This backend handles push (inserting events) and pull (streaming events via Electric's
|
|
158
|
+
* shape-based sync protocol).
|
|
159
|
+
*
|
|
160
|
+
* The endpoint should typically be part of your API layer to handle authentication,
|
|
161
|
+
* rate limiting, and proxying requests to the Electric server.
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* ```ts
|
|
165
|
+
* import { makeSyncBackend } from '@livestore/sync-electric'
|
|
166
|
+
*
|
|
167
|
+
* const adapter = makePersistedAdapter({
|
|
168
|
+
* sync: {
|
|
169
|
+
* backend: makeSyncBackend({
|
|
170
|
+
* endpoint: '/api/electric',
|
|
171
|
+
* }),
|
|
172
|
+
* },
|
|
173
|
+
* })
|
|
174
|
+
* ```
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* ```ts
|
|
178
|
+
* // With separate endpoints for push/pull/ping
|
|
179
|
+
* const backend = makeSyncBackend({
|
|
180
|
+
* endpoint: {
|
|
181
|
+
* push: '/api/push-event',
|
|
182
|
+
* pull: '/api/pull-events',
|
|
183
|
+
* ping: '/api/ping',
|
|
184
|
+
* },
|
|
185
|
+
* ping: {
|
|
186
|
+
* enabled: true,
|
|
187
|
+
* requestInterval: 15_000, // 15 seconds
|
|
188
|
+
* },
|
|
189
|
+
* })
|
|
190
|
+
* ```
|
|
191
|
+
*
|
|
192
|
+
* @see https://livestore.dev/docs/sync/electric for setup guide
|
|
193
|
+
*/
|
|
164
194
|
export const makeSyncBackend =
|
|
165
195
|
({ endpoint, ...options }: SyncBackendOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
|
|
166
196
|
({ storeId, payload }) =>
|
|
@@ -181,13 +211,13 @@ export const makeSyncBackend =
|
|
|
181
211
|
/** The batch of events */
|
|
182
212
|
ReadonlyArray<{
|
|
183
213
|
metadata: Option.Option<SyncMetadata>
|
|
184
|
-
eventEncoded: LiveStoreEvent.
|
|
214
|
+
eventEncoded: LiveStoreEvent.Global.Encoded
|
|
185
215
|
}>,
|
|
186
216
|
/** The next handle to use for the next pull */
|
|
187
217
|
Option.Option<SyncMetadata>,
|
|
188
218
|
]
|
|
189
219
|
>,
|
|
190
|
-
|
|
220
|
+
UnknownError | IsOfflineError
|
|
191
221
|
> =>
|
|
192
222
|
Effect.gen(function* () {
|
|
193
223
|
const argsJson = yield* Schema.encode(ApiSchema.ArgsSchema)(
|
|
@@ -199,7 +229,7 @@ export const makeSyncBackend =
|
|
|
199
229
|
|
|
200
230
|
if (resp.status === 401) {
|
|
201
231
|
const body = yield* resp.text.pipe(Effect.catchAll(() => Effect.succeed('-')))
|
|
202
|
-
return yield*
|
|
232
|
+
return yield* new UnknownError({
|
|
203
233
|
cause: new Error(`Unauthorized (401): Couldn't connect to ElectricSQL: ${body}`),
|
|
204
234
|
})
|
|
205
235
|
} else if (resp.status === 400) {
|
|
@@ -220,9 +250,7 @@ export const makeSyncBackend =
|
|
|
220
250
|
return notYetImplemented(`Electric shape not found`)
|
|
221
251
|
} else if (resp.status < 200 || resp.status >= 300) {
|
|
222
252
|
const body = yield* resp.text
|
|
223
|
-
return yield*
|
|
224
|
-
cause: new Error(`Unexpected status code: ${resp.status}: ${body}`),
|
|
225
|
-
})
|
|
253
|
+
return yield* new UnknownError({ cause: new Error(`Unexpected status code: ${resp.status}: ${body}`) })
|
|
226
254
|
}
|
|
227
255
|
|
|
228
256
|
const headers = yield* HttpClientResponse.schemaHeaders(ResponseHeaders)(resp)
|
|
@@ -243,7 +271,7 @@ export const makeSyncBackend =
|
|
|
243
271
|
|
|
244
272
|
// Check for delete/update operations and throw descriptive error
|
|
245
273
|
const invalidOperations = ReadonlyArray.filterMap(allItems, (item) =>
|
|
246
|
-
Schema.is(ResponseItemInvalid)(item) ? Option.some(item.headers.operation) : Option.none(),
|
|
274
|
+
Schema.is(ResponseItemInvalid)(item) === true ? Option.some(item.headers.operation) : Option.none(),
|
|
247
275
|
)
|
|
248
276
|
|
|
249
277
|
if (invalidOperations.length > 0) {
|
|
@@ -256,7 +284,7 @@ export const makeSyncBackend =
|
|
|
256
284
|
|
|
257
285
|
const items = allItems.filter(Schema.is(ResponseItemInsert)).map((item) => ({
|
|
258
286
|
metadata: Option.some({ offset: nextHandle.offset, handle: nextHandle.handle }),
|
|
259
|
-
eventEncoded: item.value
|
|
287
|
+
eventEncoded: item.value,
|
|
260
288
|
}))
|
|
261
289
|
|
|
262
290
|
yield* Effect.annotateCurrentSpan({ itemsCount: items.length, nextHandle })
|
|
@@ -264,7 +292,9 @@ export const makeSyncBackend =
|
|
|
264
292
|
return Option.some([items, Option.some(nextHandle)] as const)
|
|
265
293
|
}).pipe(
|
|
266
294
|
Effect.scoped,
|
|
267
|
-
Effect.mapError((cause) =>
|
|
295
|
+
Effect.mapError((cause) =>
|
|
296
|
+
cause._tag === 'UnknownError' ? cause : new UnknownError({ cause }),
|
|
297
|
+
),
|
|
268
298
|
Effect.withSpan('electric-provider:runPull', { attributes: { handle, live } }),
|
|
269
299
|
)
|
|
270
300
|
|
|
@@ -279,7 +309,7 @@ export const makeSyncBackend =
|
|
|
279
309
|
|
|
280
310
|
yield* SubscriptionRef.set(isConnected, true)
|
|
281
311
|
}).pipe(
|
|
282
|
-
|
|
312
|
+
UnknownError.mapToUnknownError,
|
|
283
313
|
Effect.timeout(pingTimeout),
|
|
284
314
|
Effect.catchTag('TimeoutException', () => SubscriptionRef.set(isConnected, false)),
|
|
285
315
|
Effect.withSpan('electric-provider:ping'),
|
|
@@ -294,9 +324,8 @@ export const makeSyncBackend =
|
|
|
294
324
|
|
|
295
325
|
// If the pull endpoint has the same origin as the current page, we can assume that we already have a connection
|
|
296
326
|
// otherwise we send a HEAD request to speed up the connection process
|
|
297
|
-
const connect: SyncBackend.SyncBackend<SyncMetadata>['connect'] =
|
|
298
|
-
? Effect.void
|
|
299
|
-
: ping.pipe(UnexpectedError.mapToUnexpectedError)
|
|
327
|
+
const connect: SyncBackend.SyncBackend<SyncMetadata>['connect'] =
|
|
328
|
+
pullEndpointHasSameOrigin === true ? Effect.void : ping.pipe(UnknownError.mapToUnknownError)
|
|
300
329
|
|
|
301
330
|
return SyncBackend.of({
|
|
302
331
|
connect,
|
|
@@ -306,7 +335,7 @@ export const makeSyncBackend =
|
|
|
306
335
|
return Stream.unfoldEffect(cursor.pipe(Option.flatMap((_) => _.metadata)), (metadataOption) =>
|
|
307
336
|
Effect.gen(function* () {
|
|
308
337
|
const result = yield* runPull(metadataOption, { live: options?.live ?? false })
|
|
309
|
-
if (Option.isNone(result)) return Option.none()
|
|
338
|
+
if (Option.isNone(result) === true) return Option.none()
|
|
310
339
|
|
|
311
340
|
const [batch, nextMetadataOption] = result.value
|
|
312
341
|
|
|
@@ -317,7 +346,7 @@ export const makeSyncBackend =
|
|
|
317
346
|
}
|
|
318
347
|
|
|
319
348
|
// Make sure we emit at least once even if there's no data or we're live-pulling
|
|
320
|
-
if (hasEmittedAtLeastOnce === false || options?.live) {
|
|
349
|
+
if (hasEmittedAtLeastOnce === false || options?.live === true) {
|
|
321
350
|
hasEmittedAtLeastOnce = true
|
|
322
351
|
return Option.some([{ batch, hasMore: false }, nextMetadataOption])
|
|
323
352
|
}
|
|
@@ -328,28 +357,27 @@ export const makeSyncBackend =
|
|
|
328
357
|
).pipe(
|
|
329
358
|
Stream.map(({ batch, hasMore }) => ({
|
|
330
359
|
batch,
|
|
331
|
-
pageInfo: hasMore ? SyncBackend.pageInfoMoreUnknown : SyncBackend.pageInfoNoMore,
|
|
360
|
+
pageInfo: hasMore === true ? SyncBackend.pageInfoMoreUnknown : SyncBackend.pageInfoNoMore,
|
|
332
361
|
})),
|
|
333
362
|
Stream.withSpan('electric-provider:pull'),
|
|
334
363
|
)
|
|
335
364
|
},
|
|
336
365
|
|
|
337
|
-
push: (batch)
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
}).pipe(Effect.withSpan('electric-provider:push')),
|
|
366
|
+
push: Effect.fn('electric-provider:push')(function* (batch) {
|
|
367
|
+
const resp = yield* HttpClientRequest.schemaBodyJson(ApiSchema.PushPayload)(
|
|
368
|
+
HttpClientRequest.post(pushEndpoint),
|
|
369
|
+
ApiSchema.PushPayload.make({ storeId, batch }),
|
|
370
|
+
).pipe(
|
|
371
|
+
Effect.andThen(httpClient.pipe(HttpClient.filterStatusOk).execute),
|
|
372
|
+
Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
|
|
373
|
+
Effect.scoped,
|
|
374
|
+
Effect.mapError((cause) => UnknownError.make({ cause })),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
if (resp.success === false) {
|
|
378
|
+
return yield* new UnknownError({ cause: new Error('Push failed') })
|
|
379
|
+
}
|
|
380
|
+
}),
|
|
353
381
|
ping,
|
|
354
382
|
isConnected,
|
|
355
383
|
metadata: {
|
package/src/make-electric-url.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { shouldNeverHappen } from '@livestore/utils'
|
|
2
2
|
import { Hash, Schema } from '@livestore/utils/effect'
|
|
3
|
+
|
|
3
4
|
import * as ApiSchema from './api-schema.ts'
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -75,7 +76,7 @@ export const makeElectricUrl = ({
|
|
|
75
76
|
} else {
|
|
76
77
|
searchParams.set('offset', args.handle.value.offset)
|
|
77
78
|
searchParams.set('handle', args.handle.value.handle)
|
|
78
|
-
searchParams.set('live', args.live ? 'true' : 'false')
|
|
79
|
+
searchParams.set('live', args.live === true ? 'true' : 'false')
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
const payload = args.payload
|
|
@@ -103,8 +104,27 @@ export const toTableName = (storeId: string) => {
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
/**
|
|
106
|
-
*
|
|
107
|
+
* CRITICAL: Increment this version whenever you modify the Postgres table schema structure.
|
|
108
|
+
*
|
|
109
|
+
* Bump required when:
|
|
110
|
+
* - Adding/removing/renaming columns in the eventlog table (see examples/web-todomvc-sync-electric/src/server/db.ts)
|
|
111
|
+
* - Changing column types or constraints
|
|
112
|
+
* - Modifying primary keys or indexes
|
|
113
|
+
*
|
|
114
|
+
* Bump NOT required when:
|
|
115
|
+
* - Changing query patterns or fetch logic
|
|
116
|
+
* - Adding new tables (as long as existing table schema remains unchanged)
|
|
117
|
+
* - Updating client-side implementation details
|
|
118
|
+
*
|
|
119
|
+
* Impact: Changing this version triggers a "soft reset" - new table names are created
|
|
120
|
+
* and old data becomes inaccessible (but remains in the database).
|
|
107
121
|
*
|
|
108
|
-
*
|
|
122
|
+
* Current schema (PostgreSQL):
|
|
123
|
+
* - seqNum (INTEGER PRIMARY KEY)
|
|
124
|
+
* - parentSeqNum (INTEGER)
|
|
125
|
+
* - name (TEXT NOT NULL)
|
|
126
|
+
* - args (JSONB NOT NULL)
|
|
127
|
+
* - clientId (TEXT NOT NULL)
|
|
128
|
+
* - sessionId (TEXT NOT NULL)
|
|
109
129
|
*/
|
|
110
130
|
export const PERSISTENCE_FORMAT_VERSION = 6
|