@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.
@@ -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
- }).annotations({ title: '@livestore/sync-electric.PushPayload' })
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: Schema.UndefinedOr(Schema.JsonValue),
12
- handle: Schema.Option(
13
- Schema.Struct({
14
- offset: Schema.String,
15
- handle: Schema.String,
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
- }).annotations({ title: '@livestore/sync-electric.PullPayload' })
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.compose(Schema.StringFromUriComponent, Schema.parseJson(PullPayload))
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>('~@livestore/sync-electric/InvalidOperationError')('InvalidOperationError', {
21
- operation: Schema.Literal('delete', 'update'),
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.parseJson(Schema.Any),
75
+ args: Schema.fromJsonString(Schema.Any),
73
76
  clientId: Schema.String,
74
77
  sessionId: Schema.String,
75
78
  })
76
- .pipe(Schema.compose(LiveStoreEvent.Global.Encoded))
77
- .annotations({ title: '@livestore/sync-electric:LiveStoreEventGlobalFromStringRecord' })
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
- }).annotations({ title: '@livestore/sync-electric:ResponseItemInsert' })
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.Literal('update', 'delete'), relation: Schema.Array(Schema.String) }),
91
- }).annotations({ title: '@livestore/sync-electric:ResponseItemInvalid' })
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
- }).annotations({ title: '@livestore/sync-electric:ResponseItemControl' })
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.DurationInput
139
+ requestTimeout?: Duration.Input
137
140
  /**
138
141
  * How often to send ping requests
139
142
  * @default 10 seconds
140
143
  */
141
- requestInterval?: Duration.DurationInput
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.encode(ApiSchema.ArgsSchema)(
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.catchAll(() => Effect.succeed('-')))
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 ? Option.some(item.headers.operation) : Option.none(),
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('TimeoutException', () => SubscriptionRef.set(isConnected, false)),
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.unfoldEffect(cursor.pipe(Option.flatMap((_) => _.metadata)), (metadataOption) =>
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 Option.none()
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 Option.some([{ batch, hasMore: true }, nextMetadataOption])
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 Option.some([{ batch, hasMore: false }, nextMetadataOption])
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 Option.none()
356
+ return undefined
356
357
  }),
357
358
  ).pipe(
358
359
  Stream.map(({ batch, hasMore }) => ({
@@ -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.JsonValue | undefined
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.decodeUnknownEither(UrlParamsSchema)(Object.fromEntries(providedSearchParams.entries()))
47
+ const argsResult = Schema.decodeUnknownResult(UrlParamsSchema)(Object.fromEntries(providedSearchParams.entries()))
48
48
 
49
- if (argsResult._tag === 'Left') {
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.right.args
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