@livestore/sync-electric 0.4.0 → 0.5.0-dev.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 +45 -50
- package/dist/api-schema.d.ts.map +1 -1
- package/dist/api-schema.js +13 -7
- package/dist/api-schema.js.map +1 -1
- package/dist/api-schema.test.d.ts +2 -0
- package/dist/api-schema.test.d.ts.map +1 -0
- package/dist/api-schema.test.js +42 -0
- package/dist/api-schema.test.js.map +1 -0
- package/dist/index.d.ts +8 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -20
- package/dist/index.js.map +1 -1
- package/dist/make-electric-url.d.ts +1 -1
- package/dist/make-electric-url.d.ts.map +1 -1
- package/dist/make-electric-url.js +4 -4
- package/dist/make-electric-url.js.map +1 -1
- package/package.json +23 -40
- package/src/api-schema.test.ts +49 -0
- package/src/api-schema.ts +18 -10
- package/src/index.ts +25 -24
- package/src/make-electric-url.ts +5 -5
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Option, Schema } from '@livestore/utils/effect'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import * as ApiSchema from './api-schema.ts'
|
|
5
|
+
import { makeElectricUrl } from './make-electric-url.ts'
|
|
6
|
+
|
|
7
|
+
const HandleStruct = { offset: 'off-1', handle: 'h-1' } as const
|
|
8
|
+
|
|
9
|
+
const cases = [
|
|
10
|
+
{ name: 'none + no payload', storeId: 's1', payload: undefined, handle: Option.none(), live: false },
|
|
11
|
+
{ name: 'some + payload', storeId: 's2', payload: { foo: 'bar' }, handle: Option.some(HandleStruct), live: true },
|
|
12
|
+
{ name: 'none + payload present', storeId: 's3', payload: { a: 1 }, handle: Option.none(), live: false },
|
|
13
|
+
{ name: 'some + no payload', storeId: 's4', payload: undefined, handle: Option.some(HandleStruct), live: true },
|
|
14
|
+
{ name: 'none + null payload', storeId: 's5', payload: null, handle: Option.none(), live: false },
|
|
15
|
+
] as const
|
|
16
|
+
|
|
17
|
+
describe('sync-electric ArgsSchema round-trip', () => {
|
|
18
|
+
for (const c of cases) {
|
|
19
|
+
it(`round-trips ${c.name}`, () => {
|
|
20
|
+
const input = ApiSchema.PullPayload.make({
|
|
21
|
+
storeId: c.storeId,
|
|
22
|
+
payload: c.payload,
|
|
23
|
+
handle: c.handle,
|
|
24
|
+
live: c.live,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const encoded = Schema.encodeSync(ApiSchema.ArgsSchema)(input)
|
|
28
|
+
expect(typeof encoded).toBe('string')
|
|
29
|
+
|
|
30
|
+
const decoded = Schema.decodeUnknownSync(ApiSchema.ArgsSchema)(encoded)
|
|
31
|
+
|
|
32
|
+
expect(Option.isOption(decoded.handle)).toBe(true)
|
|
33
|
+
expect(decoded.handle._tag).toBe(c.handle._tag)
|
|
34
|
+
if (Option.isSome(c.handle)) {
|
|
35
|
+
expect((decoded.handle as Option.Some<typeof HandleStruct>).value).toEqual(HandleStruct)
|
|
36
|
+
}
|
|
37
|
+
expect(decoded.payload).toEqual(c.payload)
|
|
38
|
+
expect(decoded.storeId).toBe(c.storeId)
|
|
39
|
+
expect(decoded.live).toBe(c.live)
|
|
40
|
+
|
|
41
|
+
// Full path through makeElectricUrl: URLSearchParams -> Struct({ args }) -> ArgsSchema
|
|
42
|
+
const searchParams = new URLSearchParams({ args: encoded })
|
|
43
|
+
const result = makeElectricUrl({ electricHost: 'http://electric.test', searchParams })
|
|
44
|
+
expect(result.storeId).toBe(c.storeId)
|
|
45
|
+
expect(result.needsInit).toBe(Option.isNone(c.handle))
|
|
46
|
+
expect(result.payload).toEqual(c.payload)
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
})
|
package/src/api-schema.ts
CHANGED
|
@@ -4,21 +4,29 @@ import { Schema } from '@livestore/utils/effect'
|
|
|
4
4
|
export const PushPayload = Schema.TaggedStruct('@livestore/sync-electric.Push', {
|
|
5
5
|
storeId: Schema.String,
|
|
6
6
|
batch: Schema.Array(LiveStoreEvent.Global.Encoded),
|
|
7
|
-
}).
|
|
7
|
+
}).annotate({ title: '@livestore/sync-electric.PushPayload' })
|
|
8
8
|
|
|
9
9
|
export const PullPayload = Schema.TaggedStruct('@livestore/sync-electric.Pull', {
|
|
10
10
|
storeId: Schema.String,
|
|
11
|
-
payload
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
// `optional` so an absent payload is omitted from the JSON entirely (a required key with an
|
|
12
|
+
// `undefined` value would be dropped by `JSON.stringify` and then fail decoding as "Missing key").
|
|
13
|
+
payload: Schema.optional(Schema.Json),
|
|
14
|
+
// `toCodecJson` makes the `Option` encode to its JSON-safe struct form (`{ _tag: "None" }` /
|
|
15
|
+
// `{ _tag: "Some", value }`). A bare `Schema.Option` encodes to a runtime `Option`, whose
|
|
16
|
+
// `toJSON` leaks `{"_id":"Option",...}` when `fromJsonString` runs `JSON.stringify`, and that
|
|
17
|
+
// shape then fails to decode back into an `Option`.
|
|
18
|
+
handle: Schema.toCodecJson(
|
|
19
|
+
Schema.Option(
|
|
20
|
+
Schema.Struct({
|
|
21
|
+
offset: Schema.String,
|
|
22
|
+
handle: Schema.String,
|
|
23
|
+
}),
|
|
24
|
+
),
|
|
17
25
|
),
|
|
18
26
|
live: Schema.Boolean,
|
|
19
|
-
}).
|
|
27
|
+
}).annotate({ title: '@livestore/sync-electric.PullPayload' })
|
|
20
28
|
|
|
21
|
-
export const ApiPayload = Schema.Union(PullPayload, PushPayload)
|
|
29
|
+
export const ApiPayload = Schema.Union([PullPayload, PushPayload])
|
|
22
30
|
|
|
23
31
|
// Format for the query params
|
|
24
|
-
export const ArgsSchema = Schema.
|
|
32
|
+
export const ArgsSchema = Schema.StringFromUriComponent.pipe(Schema.decodeTo(Schema.fromJsonString(PullPayload)))
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
HttpClientResponse,
|
|
10
10
|
Option,
|
|
11
11
|
ReadonlyArray,
|
|
12
|
+
Result,
|
|
12
13
|
Schedule,
|
|
13
14
|
Schema,
|
|
14
15
|
Stream,
|
|
@@ -17,8 +18,10 @@ import {
|
|
|
17
18
|
|
|
18
19
|
import * as ApiSchema from './api-schema.ts'
|
|
19
20
|
|
|
20
|
-
export class InvalidOperationError extends Schema.TaggedError<InvalidOperationError>(
|
|
21
|
-
|
|
21
|
+
export class InvalidOperationError extends Schema.TaggedError<InvalidOperationError>(
|
|
22
|
+
'~@livestore/sync-electric/InvalidOperationError',
|
|
23
|
+
)('InvalidOperationError', {
|
|
24
|
+
operation: Schema.Literals(['delete', 'update']),
|
|
22
25
|
message: Schema.String,
|
|
23
26
|
}) {}
|
|
24
27
|
|
|
@@ -69,34 +72,34 @@ const LiveStoreEventGlobalFromStringRecord = Schema.Struct({
|
|
|
69
72
|
seqNum: Schema.NumberFromString,
|
|
70
73
|
parentSeqNum: Schema.NumberFromString,
|
|
71
74
|
name: Schema.String,
|
|
72
|
-
args: Schema.
|
|
75
|
+
args: Schema.fromJsonString(Schema.Any),
|
|
73
76
|
clientId: Schema.String,
|
|
74
77
|
sessionId: Schema.String,
|
|
75
78
|
})
|
|
76
|
-
.pipe(Schema.
|
|
77
|
-
.
|
|
79
|
+
.pipe(Schema.decodeTo(LiveStoreEvent.Global.Encoded))
|
|
80
|
+
.annotate({ title: '@livestore/sync-electric:LiveStoreEventGlobalFromStringRecord' })
|
|
78
81
|
|
|
79
82
|
const ResponseItemInsert = Schema.Struct({
|
|
80
83
|
/** Postgres path (e.g. `"public"."events_9069baf0_b3e6_42f7_980f_188416eab3fx3"/"0"`) */
|
|
81
84
|
key: Schema.optional(Schema.String),
|
|
82
85
|
value: LiveStoreEventGlobalFromStringRecord,
|
|
83
86
|
headers: Schema.Struct({ operation: Schema.Literal('insert'), relation: Schema.Array(Schema.String) }),
|
|
84
|
-
}).
|
|
87
|
+
}).annotate({ title: '@livestore/sync-electric:ResponseItemInsert' })
|
|
85
88
|
|
|
86
89
|
const ResponseItemInvalid = Schema.Struct({
|
|
87
90
|
/** Postgres path (e.g. `"public"."events_9069baf0_b3e6_42f7_980f_188416eab3fx3"/"0"`) */
|
|
88
91
|
key: Schema.optional(Schema.String),
|
|
89
92
|
value: Schema.Any,
|
|
90
|
-
headers: Schema.Struct({ operation: Schema.
|
|
91
|
-
}).
|
|
93
|
+
headers: Schema.Struct({ operation: Schema.Literals(['update', 'delete']), relation: Schema.Array(Schema.String) }),
|
|
94
|
+
}).annotate({ title: '@livestore/sync-electric:ResponseItemInvalid' })
|
|
92
95
|
|
|
93
96
|
const ResponseItemControl = Schema.Struct({
|
|
94
97
|
key: Schema.optional(Schema.String),
|
|
95
98
|
value: Schema.optional(Schema.Any),
|
|
96
99
|
headers: Schema.Struct({ control: Schema.String }),
|
|
97
|
-
}).
|
|
100
|
+
}).annotate({ title: '@livestore/sync-electric:ResponseItemControl' })
|
|
98
101
|
|
|
99
|
-
const ResponseItem = Schema.Union(ResponseItemInsert, ResponseItemInvalid, ResponseItemControl)
|
|
102
|
+
const ResponseItem = Schema.Union([ResponseItemInsert, ResponseItemInvalid, ResponseItemControl])
|
|
100
103
|
|
|
101
104
|
const ResponseHeaders = Schema.Struct({
|
|
102
105
|
'electric-handle': Schema.String,
|
|
@@ -133,12 +136,12 @@ export interface SyncBackendOptions {
|
|
|
133
136
|
* How long to wait for a ping response before timing out
|
|
134
137
|
* @default 10 seconds
|
|
135
138
|
*/
|
|
136
|
-
requestTimeout?: Duration.
|
|
139
|
+
requestTimeout?: Duration.Input
|
|
137
140
|
/**
|
|
138
141
|
* How often to send ping requests
|
|
139
142
|
* @default 10 seconds
|
|
140
143
|
*/
|
|
141
|
-
requestInterval?: Duration.
|
|
144
|
+
requestInterval?: Duration.Input
|
|
142
145
|
}
|
|
143
146
|
}
|
|
144
147
|
|
|
@@ -220,7 +223,7 @@ export const makeSyncBackend =
|
|
|
220
223
|
UnknownError | IsOfflineError
|
|
221
224
|
> =>
|
|
222
225
|
Effect.gen(function* () {
|
|
223
|
-
const argsJson = yield* Schema.
|
|
226
|
+
const argsJson = yield* Schema.encodeEffect(ApiSchema.ArgsSchema)(
|
|
224
227
|
ApiSchema.PullPayload.make({ storeId, handle, payload, live }),
|
|
225
228
|
)
|
|
226
229
|
const url = `${pullEndpoint}?args=${argsJson}`
|
|
@@ -228,7 +231,7 @@ export const makeSyncBackend =
|
|
|
228
231
|
const resp = yield* httpClient.get(url)
|
|
229
232
|
|
|
230
233
|
if (resp.status === 401) {
|
|
231
|
-
const body = yield* resp.text.pipe(Effect.
|
|
234
|
+
const body = yield* resp.text.pipe(Effect.catch(() => Effect.succeed('-')))
|
|
232
235
|
return yield* new UnknownError({
|
|
233
236
|
cause: new Error(`Unauthorized (401): Couldn't connect to ElectricSQL: ${body}`),
|
|
234
237
|
})
|
|
@@ -271,7 +274,7 @@ export const makeSyncBackend =
|
|
|
271
274
|
|
|
272
275
|
// Check for delete/update operations and throw descriptive error
|
|
273
276
|
const invalidOperations = ReadonlyArray.filterMap(allItems, (item) =>
|
|
274
|
-
Schema.is(ResponseItemInvalid)(item) === true ?
|
|
277
|
+
Schema.is(ResponseItemInvalid)(item) === true ? Result.succeed(item.headers.operation) : Result.failVoid,
|
|
275
278
|
)
|
|
276
279
|
|
|
277
280
|
if (invalidOperations.length > 0) {
|
|
@@ -292,9 +295,7 @@ export const makeSyncBackend =
|
|
|
292
295
|
return Option.some([items, Option.some(nextHandle)] as const)
|
|
293
296
|
}).pipe(
|
|
294
297
|
Effect.scoped,
|
|
295
|
-
Effect.mapError((cause) =>
|
|
296
|
-
cause._tag === 'UnknownError' ? cause : new UnknownError({ cause }),
|
|
297
|
-
),
|
|
298
|
+
Effect.mapError((cause) => (Schema.is(UnknownError)(cause) === true ? cause : new UnknownError({ cause }))),
|
|
298
299
|
Effect.withSpan('electric-provider:runPull', { attributes: { handle, live } }),
|
|
299
300
|
)
|
|
300
301
|
|
|
@@ -311,7 +312,7 @@ export const makeSyncBackend =
|
|
|
311
312
|
}).pipe(
|
|
312
313
|
UnknownError.mapToUnknownError,
|
|
313
314
|
Effect.timeout(pingTimeout),
|
|
314
|
-
Effect.catchTag('
|
|
315
|
+
Effect.catchTag('TimeoutError', () => SubscriptionRef.set(isConnected, false)),
|
|
315
316
|
Effect.withSpan('electric-provider:ping'),
|
|
316
317
|
)
|
|
317
318
|
|
|
@@ -332,27 +333,27 @@ export const makeSyncBackend =
|
|
|
332
333
|
pull: (cursor, options) => {
|
|
333
334
|
let hasEmittedAtLeastOnce = false
|
|
334
335
|
|
|
335
|
-
return Stream.
|
|
336
|
+
return Stream.unfold(cursor.pipe(Option.flatMap((_) => _.metadata)), (metadataOption) =>
|
|
336
337
|
Effect.gen(function* () {
|
|
337
338
|
const result = yield* runPull(metadataOption, { live: options?.live ?? false })
|
|
338
|
-
if (Option.isNone(result) === true) return
|
|
339
|
+
if (Option.isNone(result) === true) return undefined
|
|
339
340
|
|
|
340
341
|
const [batch, nextMetadataOption] = result.value
|
|
341
342
|
|
|
342
343
|
// Continue pagination if we have data
|
|
343
344
|
if (batch.length > 0) {
|
|
344
345
|
hasEmittedAtLeastOnce = true
|
|
345
|
-
return
|
|
346
|
+
return [{ batch, hasMore: true as boolean }, nextMetadataOption] as const
|
|
346
347
|
}
|
|
347
348
|
|
|
348
349
|
// Make sure we emit at least once even if there's no data or we're live-pulling
|
|
349
350
|
if (hasEmittedAtLeastOnce === false || options?.live === true) {
|
|
350
351
|
hasEmittedAtLeastOnce = true
|
|
351
|
-
return
|
|
352
|
+
return [{ batch, hasMore: false as boolean }, nextMetadataOption] as const
|
|
352
353
|
}
|
|
353
354
|
|
|
354
355
|
// Stop on empty batch (when not live)
|
|
355
|
-
return
|
|
356
|
+
return undefined
|
|
356
357
|
}),
|
|
357
358
|
).pipe(
|
|
358
359
|
Stream.map(({ batch, hasMore }) => ({
|
package/src/make-electric-url.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { shouldNeverHappen } from '@livestore/utils'
|
|
2
|
-
import { Hash, Schema } from '@livestore/utils/effect'
|
|
2
|
+
import { Hash, Result, Schema } from '@livestore/utils/effect'
|
|
3
3
|
|
|
4
4
|
import * as ApiSchema from './api-schema.ts'
|
|
5
5
|
|
|
@@ -40,13 +40,13 @@ export const makeElectricUrl = ({
|
|
|
40
40
|
*/
|
|
41
41
|
needsInit: boolean
|
|
42
42
|
/** Sync payload provided by the client */
|
|
43
|
-
payload: Schema.
|
|
43
|
+
payload: Schema.Json | undefined
|
|
44
44
|
} => {
|
|
45
45
|
const endpointUrl = `${electricHost}/v1/shape`
|
|
46
46
|
const UrlParamsSchema = Schema.Struct({ args: ApiSchema.ArgsSchema })
|
|
47
|
-
const argsResult = Schema.
|
|
47
|
+
const argsResult = Schema.decodeUnknownResult(UrlParamsSchema)(Object.fromEntries(providedSearchParams.entries()))
|
|
48
48
|
|
|
49
|
-
if (argsResult
|
|
49
|
+
if (Result.isFailure(argsResult) === true) {
|
|
50
50
|
return shouldNeverHappen(
|
|
51
51
|
'Invalid search params provided to makeElectricUrl',
|
|
52
52
|
providedSearchParams,
|
|
@@ -54,7 +54,7 @@ export const makeElectricUrl = ({
|
|
|
54
54
|
)
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
const args = argsResult.
|
|
57
|
+
const args = argsResult.success.args
|
|
58
58
|
const tableName = toTableName(args.storeId)
|
|
59
59
|
// TODO refactor with Effect URLSearchParams schema
|
|
60
60
|
// https://electric-sql.com/openapi.html
|