@livestore/sync-electric 0.4.0-dev.9 → 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 +14 -8
- 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 +49 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +74 -40
- package/dist/index.js.map +1 -1
- package/dist/make-electric-url.d.ts +22 -3
- package/dist/make-electric-url.d.ts.map +1 -1
- package/dist/make-electric-url.js +26 -7
- package/dist/make-electric-url.js.map +1 -1
- package/package.json +51 -12
- package/src/api-schema.test.ts +49 -0
- package/src/api-schema.ts +19 -11
- package/src/index.ts +92 -63
- package/src/make-electric-url.ts +28 -8
|
@@ -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
|
import * as ApiSchema from "./api-schema.js";
|
|
4
4
|
/**
|
|
5
5
|
* This function should be called in a trusted environment (e.g. a proxy server) as it
|
|
@@ -8,11 +8,11 @@ import * as ApiSchema from "./api-schema.js";
|
|
|
8
8
|
export const makeElectricUrl = ({ electricHost, searchParams: providedSearchParams, sourceId, sourceSecret, apiSecret, }) => {
|
|
9
9
|
const endpointUrl = `${electricHost}/v1/shape`;
|
|
10
10
|
const UrlParamsSchema = Schema.Struct({ args: ApiSchema.ArgsSchema });
|
|
11
|
-
const argsResult = Schema.
|
|
12
|
-
if (argsResult
|
|
11
|
+
const argsResult = Schema.decodeUnknownResult(UrlParamsSchema)(Object.fromEntries(providedSearchParams.entries()));
|
|
12
|
+
if (Result.isFailure(argsResult) === true) {
|
|
13
13
|
return shouldNeverHappen('Invalid search params provided to makeElectricUrl', providedSearchParams, Object.fromEntries(providedSearchParams.entries()));
|
|
14
14
|
}
|
|
15
|
-
const args = argsResult.
|
|
15
|
+
const args = argsResult.success.args;
|
|
16
16
|
const tableName = toTableName(args.storeId);
|
|
17
17
|
// TODO refactor with Effect URLSearchParams schema
|
|
18
18
|
// https://electric-sql.com/openapi.html
|
|
@@ -35,7 +35,7 @@ export const makeElectricUrl = ({ electricHost, searchParams: providedSearchPara
|
|
|
35
35
|
else {
|
|
36
36
|
searchParams.set('offset', args.handle.value.offset);
|
|
37
37
|
searchParams.set('handle', args.handle.value.handle);
|
|
38
|
-
searchParams.set('live', args.live ? 'true' : 'false');
|
|
38
|
+
searchParams.set('live', args.live === true ? 'true' : 'false');
|
|
39
39
|
}
|
|
40
40
|
const payload = args.payload;
|
|
41
41
|
const url = `${endpointUrl}?${searchParams.toString()}`;
|
|
@@ -52,9 +52,28 @@ export const toTableName = (storeId) => {
|
|
|
52
52
|
return tableName;
|
|
53
53
|
};
|
|
54
54
|
/**
|
|
55
|
-
*
|
|
55
|
+
* CRITICAL: Increment this version whenever you modify the Postgres table schema structure.
|
|
56
56
|
*
|
|
57
|
-
*
|
|
57
|
+
* Bump required when:
|
|
58
|
+
* - Adding/removing/renaming columns in the eventlog table (see examples/web-todomvc-sync-electric/src/server/db.ts)
|
|
59
|
+
* - Changing column types or constraints
|
|
60
|
+
* - Modifying primary keys or indexes
|
|
61
|
+
*
|
|
62
|
+
* Bump NOT required when:
|
|
63
|
+
* - Changing query patterns or fetch logic
|
|
64
|
+
* - Adding new tables (as long as existing table schema remains unchanged)
|
|
65
|
+
* - Updating client-side implementation details
|
|
66
|
+
*
|
|
67
|
+
* Impact: Changing this version triggers a "soft reset" - new table names are created
|
|
68
|
+
* and old data becomes inaccessible (but remains in the database).
|
|
69
|
+
*
|
|
70
|
+
* Current schema (PostgreSQL):
|
|
71
|
+
* - seqNum (INTEGER PRIMARY KEY)
|
|
72
|
+
* - parentSeqNum (INTEGER)
|
|
73
|
+
* - name (TEXT NOT NULL)
|
|
74
|
+
* - args (JSONB NOT NULL)
|
|
75
|
+
* - clientId (TEXT NOT NULL)
|
|
76
|
+
* - sessionId (TEXT NOT NULL)
|
|
58
77
|
*/
|
|
59
78
|
export const PERSISTENCE_FORMAT_VERSION = 6;
|
|
60
79
|
//# sourceMappingURL=make-electric-url.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"make-electric-url.js","sourceRoot":"","sources":["../src/make-electric-url.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AACpD,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAA;
|
|
1
|
+
{"version":3,"file":"make-electric-url.js","sourceRoot":"","sources":["../src/make-electric-url.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,25 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livestore/sync-electric",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-dev.0",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/livestorejs/livestore-contrib.git"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"package.json",
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
4
14
|
"type": "module",
|
|
5
15
|
"sideEffects": false,
|
|
6
16
|
"exports": {
|
|
7
17
|
".": "./dist/index.js"
|
|
8
18
|
},
|
|
9
|
-
"dependencies": {
|
|
10
|
-
"@livestore/common": "0.4.0-dev.9",
|
|
11
|
-
"@livestore/utils": "0.4.0-dev.9"
|
|
12
|
-
},
|
|
13
|
-
"devDependencies": {},
|
|
14
|
-
"files": [
|
|
15
|
-
"package.json",
|
|
16
|
-
"src",
|
|
17
|
-
"dist"
|
|
18
|
-
],
|
|
19
|
-
"license": "Apache-2.0",
|
|
20
19
|
"publishConfig": {
|
|
21
20
|
"access": "public"
|
|
22
21
|
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@livestore/common": "0.5.0-dev.0",
|
|
24
|
+
"@livestore/utils": "0.5.0-dev.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@effect/opentelemetry": "4.0.0-rc.111",
|
|
28
|
+
"@effect/platform-browser": "4.0.0-rc.111",
|
|
29
|
+
"@effect/platform-bun": "4.0.0-rc.111",
|
|
30
|
+
"@effect/platform-node": "4.0.0-rc.111",
|
|
31
|
+
"@effect/platform-node-shared": "4.0.0-rc.111",
|
|
32
|
+
"@effect/vitest": "4.0.0-rc.111",
|
|
33
|
+
"@opentelemetry/api": "1.9.0",
|
|
34
|
+
"@opentelemetry/resources": "2.2.0",
|
|
35
|
+
"@standard-schema/spec": "1.1.0",
|
|
36
|
+
"effect": "4.0.0-rc.111",
|
|
37
|
+
"vitest": "4.1.9"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@effect/opentelemetry": "^4.0.0-rc.111",
|
|
41
|
+
"@effect/platform-browser": "^4.0.0-rc.111",
|
|
42
|
+
"@effect/platform-bun": "^4.0.0-rc.111",
|
|
43
|
+
"@effect/platform-node": "^4.0.0-rc.111",
|
|
44
|
+
"@effect/platform-node-shared": "^4.0.0-rc.111",
|
|
45
|
+
"@effect/vitest": "^4.0.0-rc.111",
|
|
46
|
+
"@opentelemetry/api": "^1.9.0",
|
|
47
|
+
"@opentelemetry/resources": "^2.2.0",
|
|
48
|
+
"@standard-schema/spec": "^1.1.0",
|
|
49
|
+
"effect": "^4.0.0-rc.111"
|
|
50
|
+
},
|
|
51
|
+
"$genie": {
|
|
52
|
+
"source": "package.json.genie.ts",
|
|
53
|
+
"warning": "DO NOT EDIT - changes will be overwritten",
|
|
54
|
+
"workspaceClosureDirs": [
|
|
55
|
+
"packages/@livestore/sync-electric",
|
|
56
|
+
"repos/livestore/packages/@livestore/common",
|
|
57
|
+
"repos/livestore/packages/@livestore/utils",
|
|
58
|
+
"repos/livestore/packages/@livestore/utils-dev",
|
|
59
|
+
"repos/livestore/packages/@livestore/webmesh"
|
|
60
|
+
]
|
|
61
|
+
},
|
|
23
62
|
"scripts": {
|
|
24
63
|
"build": "",
|
|
25
64
|
"test": "echo 'No tests yet'"
|
|
@@ -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
|
@@ -3,22 +3,30 @@ import { Schema } from '@livestore/utils/effect'
|
|
|
3
3
|
|
|
4
4
|
export const PushPayload = Schema.TaggedStruct('@livestore/sync-electric.Push', {
|
|
5
5
|
storeId: Schema.String,
|
|
6
|
-
batch: Schema.Array(LiveStoreEvent.
|
|
7
|
-
}).
|
|
6
|
+
batch: Schema.Array(LiveStoreEvent.Global.Encoded),
|
|
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
|
@@ -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 {
|
|
@@ -15,6 +9,7 @@ import {
|
|
|
15
9
|
HttpClientResponse,
|
|
16
10
|
Option,
|
|
17
11
|
ReadonlyArray,
|
|
12
|
+
Result,
|
|
18
13
|
Schedule,
|
|
19
14
|
Schema,
|
|
20
15
|
Stream,
|
|
@@ -23,8 +18,10 @@ import {
|
|
|
23
18
|
|
|
24
19
|
import * as ApiSchema from './api-schema.ts'
|
|
25
20
|
|
|
26
|
-
export class InvalidOperationError extends Schema.TaggedError<InvalidOperationError>(
|
|
27
|
-
|
|
21
|
+
export class InvalidOperationError extends Schema.TaggedError<InvalidOperationError>(
|
|
22
|
+
'~@livestore/sync-electric/InvalidOperationError',
|
|
23
|
+
)('InvalidOperationError', {
|
|
24
|
+
operation: Schema.Literals(['delete', 'update']),
|
|
28
25
|
message: Schema.String,
|
|
29
26
|
}) {}
|
|
30
27
|
|
|
@@ -75,39 +72,34 @@ const LiveStoreEventGlobalFromStringRecord = Schema.Struct({
|
|
|
75
72
|
seqNum: Schema.NumberFromString,
|
|
76
73
|
parentSeqNum: Schema.NumberFromString,
|
|
77
74
|
name: Schema.String,
|
|
78
|
-
args: Schema.
|
|
75
|
+
args: Schema.fromJsonString(Schema.Any),
|
|
79
76
|
clientId: Schema.String,
|
|
80
77
|
sessionId: Schema.String,
|
|
81
78
|
})
|
|
82
|
-
.pipe(
|
|
83
|
-
|
|
84
|
-
decode: (_) => _,
|
|
85
|
-
encode: (_) => _,
|
|
86
|
-
}),
|
|
87
|
-
)
|
|
88
|
-
.annotations({ title: '@livestore/sync-electric:LiveStoreEventGlobalFromStringRecord' })
|
|
79
|
+
.pipe(Schema.decodeTo(LiveStoreEvent.Global.Encoded))
|
|
80
|
+
.annotate({ title: '@livestore/sync-electric:LiveStoreEventGlobalFromStringRecord' })
|
|
89
81
|
|
|
90
82
|
const ResponseItemInsert = Schema.Struct({
|
|
91
83
|
/** Postgres path (e.g. `"public"."events_9069baf0_b3e6_42f7_980f_188416eab3fx3"/"0"`) */
|
|
92
84
|
key: Schema.optional(Schema.String),
|
|
93
85
|
value: LiveStoreEventGlobalFromStringRecord,
|
|
94
86
|
headers: Schema.Struct({ operation: Schema.Literal('insert'), relation: Schema.Array(Schema.String) }),
|
|
95
|
-
}).
|
|
87
|
+
}).annotate({ title: '@livestore/sync-electric:ResponseItemInsert' })
|
|
96
88
|
|
|
97
89
|
const ResponseItemInvalid = Schema.Struct({
|
|
98
90
|
/** Postgres path (e.g. `"public"."events_9069baf0_b3e6_42f7_980f_188416eab3fx3"/"0"`) */
|
|
99
91
|
key: Schema.optional(Schema.String),
|
|
100
92
|
value: Schema.Any,
|
|
101
|
-
headers: Schema.Struct({ operation: Schema.
|
|
102
|
-
}).
|
|
93
|
+
headers: Schema.Struct({ operation: Schema.Literals(['update', 'delete']), relation: Schema.Array(Schema.String) }),
|
|
94
|
+
}).annotate({ title: '@livestore/sync-electric:ResponseItemInvalid' })
|
|
103
95
|
|
|
104
96
|
const ResponseItemControl = Schema.Struct({
|
|
105
97
|
key: Schema.optional(Schema.String),
|
|
106
98
|
value: Schema.optional(Schema.Any),
|
|
107
99
|
headers: Schema.Struct({ control: Schema.String }),
|
|
108
|
-
}).
|
|
100
|
+
}).annotate({ title: '@livestore/sync-electric:ResponseItemControl' })
|
|
109
101
|
|
|
110
|
-
const ResponseItem = Schema.Union(ResponseItemInsert, ResponseItemInvalid, ResponseItemControl)
|
|
102
|
+
const ResponseItem = Schema.Union([ResponseItemInsert, ResponseItemInvalid, ResponseItemControl])
|
|
111
103
|
|
|
112
104
|
const ResponseHeaders = Schema.Struct({
|
|
113
105
|
'electric-handle': Schema.String,
|
|
@@ -144,12 +136,12 @@ export interface SyncBackendOptions {
|
|
|
144
136
|
* How long to wait for a ping response before timing out
|
|
145
137
|
* @default 10 seconds
|
|
146
138
|
*/
|
|
147
|
-
requestTimeout?: Duration.
|
|
139
|
+
requestTimeout?: Duration.Input
|
|
148
140
|
/**
|
|
149
141
|
* How often to send ping requests
|
|
150
142
|
* @default 10 seconds
|
|
151
143
|
*/
|
|
152
|
-
requestInterval?: Duration.
|
|
144
|
+
requestInterval?: Duration.Input
|
|
153
145
|
}
|
|
154
146
|
}
|
|
155
147
|
|
|
@@ -161,6 +153,47 @@ export const SyncMetadata = Schema.Struct({
|
|
|
161
153
|
|
|
162
154
|
export type SyncMetadata = typeof SyncMetadata.Type
|
|
163
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Creates a sync backend that uses ElectricSQL for real-time event synchronization.
|
|
158
|
+
*
|
|
159
|
+
* ElectricSQL enables real-time sync by streaming PostgreSQL changes to clients.
|
|
160
|
+
* This backend handles push (inserting events) and pull (streaming events via Electric's
|
|
161
|
+
* shape-based sync protocol).
|
|
162
|
+
*
|
|
163
|
+
* The endpoint should typically be part of your API layer to handle authentication,
|
|
164
|
+
* rate limiting, and proxying requests to the Electric server.
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* ```ts
|
|
168
|
+
* import { makeSyncBackend } from '@livestore/sync-electric'
|
|
169
|
+
*
|
|
170
|
+
* const adapter = makePersistedAdapter({
|
|
171
|
+
* sync: {
|
|
172
|
+
* backend: makeSyncBackend({
|
|
173
|
+
* endpoint: '/api/electric',
|
|
174
|
+
* }),
|
|
175
|
+
* },
|
|
176
|
+
* })
|
|
177
|
+
* ```
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* ```ts
|
|
181
|
+
* // With separate endpoints for push/pull/ping
|
|
182
|
+
* const backend = makeSyncBackend({
|
|
183
|
+
* endpoint: {
|
|
184
|
+
* push: '/api/push-event',
|
|
185
|
+
* pull: '/api/pull-events',
|
|
186
|
+
* ping: '/api/ping',
|
|
187
|
+
* },
|
|
188
|
+
* ping: {
|
|
189
|
+
* enabled: true,
|
|
190
|
+
* requestInterval: 15_000, // 15 seconds
|
|
191
|
+
* },
|
|
192
|
+
* })
|
|
193
|
+
* ```
|
|
194
|
+
*
|
|
195
|
+
* @see https://livestore.dev/docs/sync/electric for setup guide
|
|
196
|
+
*/
|
|
164
197
|
export const makeSyncBackend =
|
|
165
198
|
({ endpoint, ...options }: SyncBackendOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
|
|
166
199
|
({ storeId, payload }) =>
|
|
@@ -181,16 +214,16 @@ export const makeSyncBackend =
|
|
|
181
214
|
/** The batch of events */
|
|
182
215
|
ReadonlyArray<{
|
|
183
216
|
metadata: Option.Option<SyncMetadata>
|
|
184
|
-
eventEncoded: LiveStoreEvent.
|
|
217
|
+
eventEncoded: LiveStoreEvent.Global.Encoded
|
|
185
218
|
}>,
|
|
186
219
|
/** The next handle to use for the next pull */
|
|
187
220
|
Option.Option<SyncMetadata>,
|
|
188
221
|
]
|
|
189
222
|
>,
|
|
190
|
-
|
|
223
|
+
UnknownError | IsOfflineError
|
|
191
224
|
> =>
|
|
192
225
|
Effect.gen(function* () {
|
|
193
|
-
const argsJson = yield* Schema.
|
|
226
|
+
const argsJson = yield* Schema.encodeEffect(ApiSchema.ArgsSchema)(
|
|
194
227
|
ApiSchema.PullPayload.make({ storeId, handle, payload, live }),
|
|
195
228
|
)
|
|
196
229
|
const url = `${pullEndpoint}?args=${argsJson}`
|
|
@@ -198,8 +231,8 @@ export const makeSyncBackend =
|
|
|
198
231
|
const resp = yield* httpClient.get(url)
|
|
199
232
|
|
|
200
233
|
if (resp.status === 401) {
|
|
201
|
-
const body = yield* resp.text.pipe(Effect.
|
|
202
|
-
return yield*
|
|
234
|
+
const body = yield* resp.text.pipe(Effect.catch(() => Effect.succeed('-')))
|
|
235
|
+
return yield* new UnknownError({
|
|
203
236
|
cause: new Error(`Unauthorized (401): Couldn't connect to ElectricSQL: ${body}`),
|
|
204
237
|
})
|
|
205
238
|
} else if (resp.status === 400) {
|
|
@@ -220,9 +253,7 @@ export const makeSyncBackend =
|
|
|
220
253
|
return notYetImplemented(`Electric shape not found`)
|
|
221
254
|
} else if (resp.status < 200 || resp.status >= 300) {
|
|
222
255
|
const body = yield* resp.text
|
|
223
|
-
return yield*
|
|
224
|
-
cause: new Error(`Unexpected status code: ${resp.status}: ${body}`),
|
|
225
|
-
})
|
|
256
|
+
return yield* new UnknownError({ cause: new Error(`Unexpected status code: ${resp.status}: ${body}`) })
|
|
226
257
|
}
|
|
227
258
|
|
|
228
259
|
const headers = yield* HttpClientResponse.schemaHeaders(ResponseHeaders)(resp)
|
|
@@ -243,7 +274,7 @@ export const makeSyncBackend =
|
|
|
243
274
|
|
|
244
275
|
// Check for delete/update operations and throw descriptive error
|
|
245
276
|
const invalidOperations = ReadonlyArray.filterMap(allItems, (item) =>
|
|
246
|
-
Schema.is(ResponseItemInvalid)(item) ?
|
|
277
|
+
Schema.is(ResponseItemInvalid)(item) === true ? Result.succeed(item.headers.operation) : Result.failVoid,
|
|
247
278
|
)
|
|
248
279
|
|
|
249
280
|
if (invalidOperations.length > 0) {
|
|
@@ -256,7 +287,7 @@ export const makeSyncBackend =
|
|
|
256
287
|
|
|
257
288
|
const items = allItems.filter(Schema.is(ResponseItemInsert)).map((item) => ({
|
|
258
289
|
metadata: Option.some({ offset: nextHandle.offset, handle: nextHandle.handle }),
|
|
259
|
-
eventEncoded: item.value
|
|
290
|
+
eventEncoded: item.value,
|
|
260
291
|
}))
|
|
261
292
|
|
|
262
293
|
yield* Effect.annotateCurrentSpan({ itemsCount: items.length, nextHandle })
|
|
@@ -264,7 +295,7 @@ export const makeSyncBackend =
|
|
|
264
295
|
return Option.some([items, Option.some(nextHandle)] as const)
|
|
265
296
|
}).pipe(
|
|
266
297
|
Effect.scoped,
|
|
267
|
-
Effect.mapError((cause) => (cause
|
|
298
|
+
Effect.mapError((cause) => (Schema.is(UnknownError)(cause) === true ? cause : new UnknownError({ cause }))),
|
|
268
299
|
Effect.withSpan('electric-provider:runPull', { attributes: { handle, live } }),
|
|
269
300
|
)
|
|
270
301
|
|
|
@@ -279,9 +310,9 @@ export const makeSyncBackend =
|
|
|
279
310
|
|
|
280
311
|
yield* SubscriptionRef.set(isConnected, true)
|
|
281
312
|
}).pipe(
|
|
282
|
-
|
|
313
|
+
UnknownError.mapToUnknownError,
|
|
283
314
|
Effect.timeout(pingTimeout),
|
|
284
|
-
Effect.catchTag('
|
|
315
|
+
Effect.catchTag('TimeoutError', () => SubscriptionRef.set(isConnected, false)),
|
|
285
316
|
Effect.withSpan('electric-provider:ping'),
|
|
286
317
|
)
|
|
287
318
|
|
|
@@ -294,62 +325,60 @@ export const makeSyncBackend =
|
|
|
294
325
|
|
|
295
326
|
// If the pull endpoint has the same origin as the current page, we can assume that we already have a connection
|
|
296
327
|
// 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)
|
|
328
|
+
const connect: SyncBackend.SyncBackend<SyncMetadata>['connect'] =
|
|
329
|
+
pullEndpointHasSameOrigin === true ? Effect.void : ping.pipe(UnknownError.mapToUnknownError)
|
|
300
330
|
|
|
301
331
|
return SyncBackend.of({
|
|
302
332
|
connect,
|
|
303
333
|
pull: (cursor, options) => {
|
|
304
334
|
let hasEmittedAtLeastOnce = false
|
|
305
335
|
|
|
306
|
-
return Stream.
|
|
336
|
+
return Stream.unfold(cursor.pipe(Option.flatMap((_) => _.metadata)), (metadataOption) =>
|
|
307
337
|
Effect.gen(function* () {
|
|
308
338
|
const result = yield* runPull(metadataOption, { live: options?.live ?? false })
|
|
309
|
-
if (Option.isNone(result)) return
|
|
339
|
+
if (Option.isNone(result) === true) return undefined
|
|
310
340
|
|
|
311
341
|
const [batch, nextMetadataOption] = result.value
|
|
312
342
|
|
|
313
343
|
// Continue pagination if we have data
|
|
314
344
|
if (batch.length > 0) {
|
|
315
345
|
hasEmittedAtLeastOnce = true
|
|
316
|
-
return
|
|
346
|
+
return [{ batch, hasMore: true as boolean }, nextMetadataOption] as const
|
|
317
347
|
}
|
|
318
348
|
|
|
319
349
|
// Make sure we emit at least once even if there's no data or we're live-pulling
|
|
320
|
-
if (hasEmittedAtLeastOnce === false || options?.live) {
|
|
350
|
+
if (hasEmittedAtLeastOnce === false || options?.live === true) {
|
|
321
351
|
hasEmittedAtLeastOnce = true
|
|
322
|
-
return
|
|
352
|
+
return [{ batch, hasMore: false as boolean }, nextMetadataOption] as const
|
|
323
353
|
}
|
|
324
354
|
|
|
325
355
|
// Stop on empty batch (when not live)
|
|
326
|
-
return
|
|
356
|
+
return undefined
|
|
327
357
|
}),
|
|
328
358
|
).pipe(
|
|
329
359
|
Stream.map(({ batch, hasMore }) => ({
|
|
330
360
|
batch,
|
|
331
|
-
pageInfo: hasMore ? SyncBackend.pageInfoMoreUnknown : SyncBackend.pageInfoNoMore,
|
|
361
|
+
pageInfo: hasMore === true ? SyncBackend.pageInfoMoreUnknown : SyncBackend.pageInfoNoMore,
|
|
332
362
|
})),
|
|
333
363
|
Stream.withSpan('electric-provider:pull'),
|
|
334
364
|
)
|
|
335
365
|
},
|
|
336
366
|
|
|
337
|
-
push: (batch)
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
}).pipe(Effect.withSpan('electric-provider:push')),
|
|
367
|
+
push: Effect.fn('electric-provider:push')(function* (batch) {
|
|
368
|
+
const resp = yield* HttpClientRequest.schemaBodyJson(ApiSchema.PushPayload)(
|
|
369
|
+
HttpClientRequest.post(pushEndpoint),
|
|
370
|
+
ApiSchema.PushPayload.make({ storeId, batch }),
|
|
371
|
+
).pipe(
|
|
372
|
+
Effect.andThen(httpClient.pipe(HttpClient.filterStatusOk).execute),
|
|
373
|
+
Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
|
|
374
|
+
Effect.scoped,
|
|
375
|
+
Effect.mapError((cause) => UnknownError.make({ cause })),
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
if (resp.success === false) {
|
|
379
|
+
return yield* new UnknownError({ cause: new Error('Push failed') })
|
|
380
|
+
}
|
|
381
|
+
}),
|
|
353
382
|
ping,
|
|
354
383
|
isConnected,
|
|
355
384
|
metadata: {
|
package/src/make-electric-url.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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
|
import * as ApiSchema from './api-schema.ts'
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -39,13 +40,13 @@ export const makeElectricUrl = ({
|
|
|
39
40
|
*/
|
|
40
41
|
needsInit: boolean
|
|
41
42
|
/** Sync payload provided by the client */
|
|
42
|
-
payload: Schema.
|
|
43
|
+
payload: Schema.Json | undefined
|
|
43
44
|
} => {
|
|
44
45
|
const endpointUrl = `${electricHost}/v1/shape`
|
|
45
46
|
const UrlParamsSchema = Schema.Struct({ args: ApiSchema.ArgsSchema })
|
|
46
|
-
const argsResult = Schema.
|
|
47
|
+
const argsResult = Schema.decodeUnknownResult(UrlParamsSchema)(Object.fromEntries(providedSearchParams.entries()))
|
|
47
48
|
|
|
48
|
-
if (argsResult
|
|
49
|
+
if (Result.isFailure(argsResult) === true) {
|
|
49
50
|
return shouldNeverHappen(
|
|
50
51
|
'Invalid search params provided to makeElectricUrl',
|
|
51
52
|
providedSearchParams,
|
|
@@ -53,7 +54,7 @@ export const makeElectricUrl = ({
|
|
|
53
54
|
)
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
const args = argsResult.
|
|
57
|
+
const args = argsResult.success.args
|
|
57
58
|
const tableName = toTableName(args.storeId)
|
|
58
59
|
// TODO refactor with Effect URLSearchParams schema
|
|
59
60
|
// https://electric-sql.com/openapi.html
|
|
@@ -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
|