@livestore/sync-electric 0.4.0 → 0.5.0-dev.1

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/package.json CHANGED
@@ -1,80 +1,63 @@
1
1
  {
2
+ "$genie": {
3
+ "source": "package.json.genie.ts",
4
+ "warning": "DO NOT EDIT - changes will be overwritten",
5
+ "workspaceClosureDirs": [
6
+ "packages/@livestore/sync-electric",
7
+ "repos/livestore/packages/@livestore/common",
8
+ "repos/livestore/packages/@livestore/utils",
9
+ "repos/livestore/packages/@livestore/utils-dev",
10
+ "repos/livestore/packages/@livestore/webmesh"
11
+ ]
12
+ },
2
13
  "name": "@livestore/sync-electric",
3
- "version": "0.4.0",
14
+ "version": "0.5.0-dev.1",
15
+ "type": "module",
16
+ "sideEffects": false,
4
17
  "license": "Apache-2.0",
5
18
  "repository": {
6
19
  "type": "git",
7
- "url": "git+https://github.com/livestorejs/livestore.git"
20
+ "url": "git+https://github.com/livestorejs/livestore-contrib.git"
21
+ },
22
+ "exports": {
23
+ ".": "./dist/index.js"
8
24
  },
9
25
  "files": [
10
26
  "dist",
11
27
  "package.json",
12
28
  "src"
13
29
  ],
14
- "type": "module",
15
- "sideEffects": false,
16
- "exports": {
17
- ".": "./dist/index.js"
18
- },
19
- "publishConfig": {
20
- "access": "public"
21
- },
22
30
  "dependencies": {
23
- "@livestore/common": "^0.4.0",
24
- "@livestore/utils": "^0.4.0"
31
+ "@livestore/common": "0.5.0-dev.1",
32
+ "@livestore/utils": "0.5.0-dev.1"
25
33
  },
26
34
  "devDependencies": {
27
- "@effect/ai": "0.35.0",
28
- "@effect/cli": "0.75.1",
29
- "@effect/cluster": "0.58.2",
30
- "@effect/experimental": "0.60.0",
31
- "@effect/opentelemetry": "0.63.0",
32
- "@effect/platform": "0.96.1",
33
- "@effect/platform-browser": "0.76.0",
34
- "@effect/platform-bun": "0.89.0",
35
- "@effect/platform-node": "0.106.0",
36
- "@effect/printer": "0.49.0",
37
- "@effect/printer-ansi": "0.49.0",
38
- "@effect/rpc": "0.75.1",
39
- "@effect/sql": "0.51.1",
40
- "@effect/typeclass": "0.40.0",
41
- "@effect/vitest": "0.29.0",
35
+ "@effect/opentelemetry": "4.0.3",
36
+ "@effect/platform-browser": "4.0.3",
37
+ "@effect/platform-bun": "4.0.3",
38
+ "@effect/platform-node": "4.0.3",
39
+ "@effect/platform-node-shared": "4.0.3",
40
+ "@effect/vitest": "4.0.3",
42
41
  "@opentelemetry/api": "1.9.0",
43
42
  "@opentelemetry/resources": "2.2.0",
44
43
  "@standard-schema/spec": "1.1.0",
45
- "effect": "3.21.2"
44
+ "effect": "4.0.3",
45
+ "vitest": "4.1.9"
46
46
  },
47
47
  "peerDependencies": {
48
- "@effect/ai": "^0.35.0",
49
- "@effect/cli": "^0.75.1",
50
- "@effect/cluster": "^0.58.2",
51
- "@effect/experimental": "^0.60.0",
52
- "@effect/opentelemetry": "^0.63.0",
53
- "@effect/platform": "^0.96.1",
54
- "@effect/platform-browser": "^0.76.0",
55
- "@effect/platform-bun": "^0.89.0",
56
- "@effect/platform-node": "^0.106.0",
57
- "@effect/printer": "^0.49.0",
58
- "@effect/printer-ansi": "^0.49.0",
59
- "@effect/rpc": "^0.75.1",
60
- "@effect/sql": "^0.51.1",
61
- "@effect/typeclass": "^0.40.0",
62
- "@effect/vitest": "^0.29.0",
48
+ "@effect/opentelemetry": "^4.0.3",
49
+ "@effect/platform-browser": "^4.0.3",
50
+ "@effect/platform-bun": "^4.0.3",
51
+ "@effect/platform-node": "^4.0.3",
52
+ "@effect/platform-node-shared": "^4.0.3",
53
+ "@effect/vitest": "^4.0.3",
63
54
  "@opentelemetry/api": "^1.9.0",
64
55
  "@opentelemetry/resources": "^2.2.0",
65
56
  "@standard-schema/spec": "^1.1.0",
66
- "effect": "^3.21.2"
57
+ "effect": "^4.0.3"
67
58
  },
68
- "$genie": {
69
- "source": "package.json.genie.ts",
70
- "warning": "DO NOT EDIT - changes will be overwritten",
71
- "workspaceClosureDirs": [
72
- "packages/@livestore/common",
73
- "packages/@livestore/sync-electric",
74
- "packages/@livestore/utils",
75
- "packages/@livestore/utils-dev",
76
- "packages/@livestore/webmesh"
77
- ]
59
+ "publishConfig": {
60
+ "access": "public"
78
61
  },
79
62
  "scripts": {
80
63
  "build": "",
@@ -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.decodeSync(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) === true) {
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
 
@@ -66,37 +69,37 @@ Also see: https://github.com/electric-sql/electric/blob/main/packages/typescript
66
69
  */
67
70
 
68
71
  const LiveStoreEventGlobalFromStringRecord = Schema.Struct({
69
- seqNum: Schema.NumberFromString,
70
- parentSeqNum: Schema.NumberFromString,
72
+ seqNum: Schema.FiniteFromString,
73
+ parentSeqNum: Schema.FiniteFromString,
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.orElseSucceed(() => '-'))
232
235
  return yield* new UnknownError({
233
236
  cause: new Error(`Unauthorized (401): Couldn't connect to ElectricSQL: ${body}`),
234
237
  })
@@ -265,13 +268,11 @@ export const makeSyncBackend =
265
268
  return Option.some([[], Option.some(nextHandle)] as const)
266
269
  }
267
270
 
268
- const allItems = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem), {
269
- onExcessProperty: 'preserve',
270
- })(resp)
271
+ const allItems = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem))(resp)
271
272
 
272
273
  // Check for delete/update operations and throw descriptive error
273
274
  const invalidOperations = ReadonlyArray.filterMap(allItems, (item) =>
274
- Schema.is(ResponseItemInvalid)(item) === true ? Option.some(item.headers.operation) : Option.none(),
275
+ Schema.is(ResponseItemInvalid)(item) === true ? Result.succeed(item.headers.operation) : Result.failVoid,
275
276
  )
276
277
 
277
278
  if (invalidOperations.length > 0) {
@@ -292,9 +293,7 @@ export const makeSyncBackend =
292
293
  return Option.some([items, Option.some(nextHandle)] as const)
293
294
  }).pipe(
294
295
  Effect.scoped,
295
- Effect.mapError((cause) =>
296
- cause._tag === 'UnknownError' ? cause : new UnknownError({ cause }),
297
- ),
296
+ Effect.mapError((cause) => (Schema.is(UnknownError)(cause) === true ? cause : new UnknownError({ cause }))),
298
297
  Effect.withSpan('electric-provider:runPull', { attributes: { handle, live } }),
299
298
  )
300
299
 
@@ -310,8 +309,10 @@ export const makeSyncBackend =
310
309
  yield* SubscriptionRef.set(isConnected, true)
311
310
  }).pipe(
312
311
  UnknownError.mapToUnknownError,
313
- Effect.timeout(pingTimeout),
314
- Effect.catchTag('TimeoutException', () => SubscriptionRef.set(isConnected, false)),
312
+ Effect.timeoutOrElse({
313
+ duration: pingTimeout,
314
+ orElse: () => SubscriptionRef.set(isConnected, false),
315
+ }),
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