@livestore/sync-electric 0.4.0-dev.2 → 0.4.0-dev.21
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 +13 -0
- package/dist/api-schema.d.ts.map +1 -1
- package/dist/api-schema.js +4 -1
- package/dist/api-schema.js.map +1 -1
- package/dist/index.d.ts +68 -37
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +151 -92
- package/dist/index.js.map +1 -1
- package/dist/make-electric-url.d.ts +61 -0
- package/dist/make-electric-url.d.ts.map +1 -0
- package/dist/make-electric-url.js +79 -0
- package/dist/make-electric-url.js.map +1 -0
- package/package.json +3 -3
- package/src/api-schema.ts +5 -1
- package/src/index.ts +210 -161
- package/src/make-electric-url.ts +129 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { shouldNeverHappen } from '@livestore/utils';
|
|
2
|
+
import { Hash, Schema } from '@livestore/utils/effect';
|
|
3
|
+
import * as ApiSchema from "./api-schema.js";
|
|
4
|
+
/**
|
|
5
|
+
* This function should be called in a trusted environment (e.g. a proxy server) as it
|
|
6
|
+
* requires access to senstive information (e.g. `apiSecret` / `sourceSecret`).
|
|
7
|
+
*/
|
|
8
|
+
export const makeElectricUrl = ({ electricHost, searchParams: providedSearchParams, sourceId, sourceSecret, apiSecret, }) => {
|
|
9
|
+
const endpointUrl = `${electricHost}/v1/shape`;
|
|
10
|
+
const UrlParamsSchema = Schema.Struct({ args: ApiSchema.ArgsSchema });
|
|
11
|
+
const argsResult = Schema.decodeUnknownEither(UrlParamsSchema)(Object.fromEntries(providedSearchParams.entries()));
|
|
12
|
+
if (argsResult._tag === 'Left') {
|
|
13
|
+
return shouldNeverHappen('Invalid search params provided to makeElectricUrl', providedSearchParams, Object.fromEntries(providedSearchParams.entries()));
|
|
14
|
+
}
|
|
15
|
+
const args = argsResult.right.args;
|
|
16
|
+
const tableName = toTableName(args.storeId);
|
|
17
|
+
// TODO refactor with Effect URLSearchParams schema
|
|
18
|
+
// https://electric-sql.com/openapi.html
|
|
19
|
+
const searchParams = new URLSearchParams();
|
|
20
|
+
// Electric requires table names with capital letters to be quoted
|
|
21
|
+
// Since our table names include the storeId which may have capitals, we always quote
|
|
22
|
+
searchParams.set('table', `"${tableName}"`);
|
|
23
|
+
if (sourceId !== undefined) {
|
|
24
|
+
searchParams.set('source_id', sourceId);
|
|
25
|
+
}
|
|
26
|
+
if (sourceSecret !== undefined) {
|
|
27
|
+
searchParams.set('source_secret', sourceSecret);
|
|
28
|
+
}
|
|
29
|
+
if (apiSecret !== undefined) {
|
|
30
|
+
searchParams.set('api_secret', apiSecret);
|
|
31
|
+
}
|
|
32
|
+
if (args.handle._tag === 'None') {
|
|
33
|
+
searchParams.set('offset', '-1');
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
searchParams.set('offset', args.handle.value.offset);
|
|
37
|
+
searchParams.set('handle', args.handle.value.handle);
|
|
38
|
+
searchParams.set('live', args.live ? 'true' : 'false');
|
|
39
|
+
}
|
|
40
|
+
const payload = args.payload;
|
|
41
|
+
const url = `${endpointUrl}?${searchParams.toString()}`;
|
|
42
|
+
return { url, storeId: args.storeId, needsInit: args.handle._tag === 'None', payload };
|
|
43
|
+
};
|
|
44
|
+
export const toTableName = (storeId) => {
|
|
45
|
+
const escapedStoreId = storeId.replaceAll(/[^a-zA-Z0-9_]/g, '_');
|
|
46
|
+
const tableName = `eventlog_${PERSISTENCE_FORMAT_VERSION}_${escapedStoreId}`;
|
|
47
|
+
if (tableName.length > 63) {
|
|
48
|
+
const hashedStoreId = Hash.string(storeId);
|
|
49
|
+
console.warn(`Table name is too long: "${tableName}". Postgres table names are limited to 63 characters. Using hashed storeId instead: "${hashedStoreId}".`);
|
|
50
|
+
return `eventlog_${PERSISTENCE_FORMAT_VERSION}_hash_${hashedStoreId}`;
|
|
51
|
+
}
|
|
52
|
+
return tableName;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* CRITICAL: Increment this version whenever you modify the Postgres table schema structure.
|
|
56
|
+
*
|
|
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)
|
|
77
|
+
*/
|
|
78
|
+
export const PERSISTENCE_FORMAT_VERSION = 6;
|
|
79
|
+
//# sourceMappingURL=make-electric-url.js.map
|
|
@@ -0,0 +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;AACtD,OAAO,KAAK,SAAS,MAAM,iBAAiB,CAAA;AAE5C;;;GAGG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,EAC9B,YAAY,EACZ,YAAY,EAAE,oBAAoB,EAClC,QAAQ,EACR,YAAY,EACZ,SAAS,GAgBV,EAaC,EAAE;IACF,MAAM,WAAW,GAAG,GAAG,YAAY,WAAW,CAAA;IAC9C,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,SAAS,CAAC,UAAU,EAAE,CAAC,CAAA;IACrE,MAAM,UAAU,GAAG,MAAM,CAAC,mBAAmB,CAAC,eAAe,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,oBAAoB,CAAC,OAAO,EAAE,CAAC,CAAC,CAAA;IAElH,IAAI,UAAU,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;QAC/B,OAAO,iBAAiB,CACtB,mDAAmD,EACnD,oBAAoB,EACpB,MAAM,CAAC,WAAW,CAAC,oBAAoB,CAAC,OAAO,EAAE,CAAC,CACnD,CAAA;IACH,CAAC;IAED,MAAM,IAAI,GAAG,UAAU,CAAC,KAAK,CAAC,IAAI,CAAA;IAClC,MAAM,SAAS,GAAG,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;IAC3C,mDAAmD;IACnD,wCAAwC;IACxC,MAAM,YAAY,GAAG,IAAI,eAAe,EAAE,CAAA;IAC1C,kEAAkE;IAClE,qFAAqF;IACrF,YAAY,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,SAAS,GAAG,CAAC,CAAA;IAC3C,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;QAC3B,YAAY,CAAC,GAAG,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAA;IACzC,CAAC;IACD,IAAI,YAAY,KAAK,SAAS,EAAE,CAAC;QAC/B,YAAY,CAAC,GAAG,CAAC,eAAe,EAAE,YAAY,CAAC,CAAA;IACjD,CAAC;IACD,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;QAC5B,YAAY,CAAC,GAAG,CAAC,YAAY,EAAE,SAAS,CAAC,CAAA;IAC3C,CAAC;IACD,IAAI,IAAI,CAAC,MAAM,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;QAChC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAA;IAClC,CAAC;SAAM,CAAC;QACN,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAA;QACpD,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAA;QACpD,YAAY,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,CAAA;IACxD,CAAC;IAED,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAA;IAE5B,MAAM,GAAG,GAAG,GAAG,WAAW,IAAI,YAAY,CAAC,QAAQ,EAAE,EAAE,CAAA;IAEvD,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,SAAS,EAAE,IAAI,CAAC,MAAM,CAAC,IAAI,KAAK,MAAM,EAAE,OAAO,EAAE,CAAA;AACxF,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,OAAe,EAAE,EAAE;IAC7C,MAAM,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC,gBAAgB,EAAE,GAAG,CAAC,CAAA;IAChE,MAAM,SAAS,GAAG,YAAY,0BAA0B,IAAI,cAAc,EAAE,CAAA;IAE5E,IAAI,SAAS,CAAC,MAAM,GAAG,EAAE,EAAE,CAAC;QAC1B,MAAM,aAAa,GAAG,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;QAE1C,OAAO,CAAC,IAAI,CACV,4BAA4B,SAAS,wFAAwF,aAAa,IAAI,CAC/I,CAAA;QAED,OAAO,YAAY,0BAA0B,SAAS,aAAa,EAAE,CAAA;IACvE,CAAC;IAED,OAAO,SAAS,CAAA;AAClB,CAAC,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG,CAAC,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livestore/sync-electric",
|
|
3
|
-
"version": "0.4.0-dev.
|
|
3
|
+
"version": "0.4.0-dev.21",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./dist/index.js"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@livestore/common": "0.4.0-dev.
|
|
11
|
-
"@livestore/utils": "0.4.0-dev.
|
|
10
|
+
"@livestore/common": "0.4.0-dev.21",
|
|
11
|
+
"@livestore/utils": "0.4.0-dev.21"
|
|
12
12
|
},
|
|
13
13
|
"devDependencies": {},
|
|
14
14
|
"files": [
|
package/src/api-schema.ts
CHANGED
|
@@ -3,7 +3,7 @@ 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.
|
|
6
|
+
batch: Schema.Array(LiveStoreEvent.Global.Encoded),
|
|
7
7
|
}).annotations({ title: '@livestore/sync-electric.PushPayload' })
|
|
8
8
|
|
|
9
9
|
export const PullPayload = Schema.TaggedStruct('@livestore/sync-electric.Pull', {
|
|
@@ -15,6 +15,10 @@ export const PullPayload = Schema.TaggedStruct('@livestore/sync-electric.Pull',
|
|
|
15
15
|
handle: Schema.String,
|
|
16
16
|
}),
|
|
17
17
|
),
|
|
18
|
+
live: Schema.Boolean,
|
|
18
19
|
}).annotations({ title: '@livestore/sync-electric.PullPayload' })
|
|
19
20
|
|
|
20
21
|
export const ApiPayload = Schema.Union(PullPayload, PushPayload)
|
|
22
|
+
|
|
23
|
+
// Format for the query params
|
|
24
|
+
export const ArgsSchema = Schema.compose(Schema.StringFromUriComponent, Schema.parseJson(PullPayload))
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { InvalidPullError, InvalidPushError, UnexpectedError } from '@livestore/common'
|
|
1
|
+
import { InvalidPullError, InvalidPushError, type IsOfflineError, SyncBackend, UnknownError } from '@livestore/common'
|
|
3
2
|
import { LiveStoreEvent } from '@livestore/common/schema'
|
|
4
|
-
import { notYetImplemented
|
|
3
|
+
import { notYetImplemented } from '@livestore/utils'
|
|
5
4
|
import {
|
|
6
|
-
|
|
5
|
+
type Duration,
|
|
7
6
|
Effect,
|
|
8
7
|
HttpClient,
|
|
9
8
|
HttpClientRequest,
|
|
10
9
|
HttpClientResponse,
|
|
11
10
|
Option,
|
|
11
|
+
ReadonlyArray,
|
|
12
|
+
Schedule,
|
|
12
13
|
Schema,
|
|
13
14
|
Stream,
|
|
14
15
|
SubscriptionRef,
|
|
@@ -16,7 +17,13 @@ import {
|
|
|
16
17
|
|
|
17
18
|
import * as ApiSchema from './api-schema.ts'
|
|
18
19
|
|
|
20
|
+
export class InvalidOperationError extends Schema.TaggedError<InvalidOperationError>()('InvalidOperationError', {
|
|
21
|
+
operation: Schema.Literal('delete', 'update'),
|
|
22
|
+
message: Schema.String,
|
|
23
|
+
}) {}
|
|
24
|
+
|
|
19
25
|
export * as ApiSchema from './api-schema.ts'
|
|
26
|
+
export * from './make-electric-url.ts'
|
|
20
27
|
|
|
21
28
|
/*
|
|
22
29
|
Example data:
|
|
@@ -65,27 +72,31 @@ const LiveStoreEventGlobalFromStringRecord = Schema.Struct({
|
|
|
65
72
|
args: Schema.parseJson(Schema.Any),
|
|
66
73
|
clientId: Schema.String,
|
|
67
74
|
sessionId: Schema.String,
|
|
68
|
-
})
|
|
69
|
-
Schema.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
const ResponseItem = Schema.Struct({
|
|
75
|
+
})
|
|
76
|
+
.pipe(Schema.compose(LiveStoreEvent.Global.Encoded))
|
|
77
|
+
.annotations({ title: '@livestore/sync-electric:LiveStoreEventGlobalFromStringRecord' })
|
|
78
|
+
|
|
79
|
+
const ResponseItemInsert = Schema.Struct({
|
|
76
80
|
/** Postgres path (e.g. `"public"."events_9069baf0_b3e6_42f7_980f_188416eab3fx3"/"0"`) */
|
|
77
81
|
key: Schema.optional(Schema.String),
|
|
78
|
-
value:
|
|
79
|
-
headers: Schema.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
82
|
+
value: LiveStoreEventGlobalFromStringRecord,
|
|
83
|
+
headers: Schema.Struct({ operation: Schema.Literal('insert'), relation: Schema.Array(Schema.String) }),
|
|
84
|
+
}).annotations({ title: '@livestore/sync-electric:ResponseItemInsert' })
|
|
85
|
+
|
|
86
|
+
const ResponseItemInvalid = Schema.Struct({
|
|
87
|
+
/** Postgres path (e.g. `"public"."events_9069baf0_b3e6_42f7_980f_188416eab3fx3"/"0"`) */
|
|
88
|
+
key: Schema.optional(Schema.String),
|
|
89
|
+
value: Schema.Any,
|
|
90
|
+
headers: Schema.Struct({ operation: Schema.Literal('update', 'delete'), relation: Schema.Array(Schema.String) }),
|
|
91
|
+
}).annotations({ title: '@livestore/sync-electric:ResponseItemInvalid' })
|
|
92
|
+
|
|
93
|
+
const ResponseItemControl = Schema.Struct({
|
|
94
|
+
key: Schema.optional(Schema.String),
|
|
95
|
+
value: Schema.optional(Schema.Any),
|
|
96
|
+
headers: Schema.Struct({ control: Schema.String }),
|
|
97
|
+
}).annotations({ title: '@livestore/sync-electric:ResponseItemControl' })
|
|
98
|
+
|
|
99
|
+
const ResponseItem = Schema.Union(ResponseItemInsert, ResponseItemInvalid, ResponseItemControl)
|
|
89
100
|
|
|
90
101
|
const ResponseHeaders = Schema.Struct({
|
|
91
102
|
'electric-handle': Schema.String,
|
|
@@ -94,77 +105,8 @@ const ResponseHeaders = Schema.Struct({
|
|
|
94
105
|
'electric-offset': Schema.String,
|
|
95
106
|
})
|
|
96
107
|
|
|
97
|
-
export const syncBackend = {} as any
|
|
98
|
-
|
|
99
108
|
export const syncBackendOptions = <TOptions extends SyncBackendOptions>(options: TOptions) => options
|
|
100
109
|
|
|
101
|
-
/**
|
|
102
|
-
* This function should be called in a trusted environment (e.g. a proxy server) as it
|
|
103
|
-
* requires access to senstive information (e.g. `apiSecret` / `sourceSecret`).
|
|
104
|
-
*/
|
|
105
|
-
export const makeElectricUrl = ({
|
|
106
|
-
electricHost,
|
|
107
|
-
searchParams: providedSearchParams,
|
|
108
|
-
sourceId,
|
|
109
|
-
sourceSecret,
|
|
110
|
-
apiSecret,
|
|
111
|
-
}: {
|
|
112
|
-
electricHost: string
|
|
113
|
-
/**
|
|
114
|
-
* Needed to extract information from the search params which the `@livestore/sync-electric`
|
|
115
|
-
* client implementation automatically adds:
|
|
116
|
-
* - `handle`: the ElectricSQL handle
|
|
117
|
-
* - `storeId`: the Livestore storeId
|
|
118
|
-
*/
|
|
119
|
-
searchParams: URLSearchParams
|
|
120
|
-
/** Needed for Electric Cloud */
|
|
121
|
-
sourceId?: string
|
|
122
|
-
/** Needed for Electric Cloud */
|
|
123
|
-
sourceSecret?: string
|
|
124
|
-
/** For self-hosted ElectricSQL */
|
|
125
|
-
apiSecret?: string
|
|
126
|
-
}) => {
|
|
127
|
-
const endpointUrl = `${electricHost}/v1/shape`
|
|
128
|
-
const argsResult = Schema.decodeUnknownEither(Schema.Struct({ args: Schema.parseJson(ApiSchema.PullPayload) }))(
|
|
129
|
-
Object.fromEntries(providedSearchParams.entries()),
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
if (argsResult._tag === 'Left') {
|
|
133
|
-
return shouldNeverHappen(
|
|
134
|
-
'Invalid search params provided to makeElectricUrl',
|
|
135
|
-
providedSearchParams,
|
|
136
|
-
Object.fromEntries(providedSearchParams.entries()),
|
|
137
|
-
)
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const args = argsResult.right.args
|
|
141
|
-
const tableName = toTableName(args.storeId)
|
|
142
|
-
const searchParams = new URLSearchParams()
|
|
143
|
-
searchParams.set('table', tableName)
|
|
144
|
-
if (sourceId !== undefined) {
|
|
145
|
-
searchParams.set('source_id', sourceId)
|
|
146
|
-
}
|
|
147
|
-
if (sourceSecret !== undefined) {
|
|
148
|
-
searchParams.set('source_secret', sourceSecret)
|
|
149
|
-
}
|
|
150
|
-
if (apiSecret !== undefined) {
|
|
151
|
-
searchParams.set('api_secret', apiSecret)
|
|
152
|
-
}
|
|
153
|
-
if (args.handle._tag === 'None') {
|
|
154
|
-
searchParams.set('offset', '-1')
|
|
155
|
-
} else {
|
|
156
|
-
searchParams.set('offset', args.handle.value.offset)
|
|
157
|
-
searchParams.set('handle', args.handle.value.handle)
|
|
158
|
-
searchParams.set('live', 'true')
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const payload = args.payload
|
|
162
|
-
|
|
163
|
-
const url = `${endpointUrl}?${searchParams.toString()}`
|
|
164
|
-
|
|
165
|
-
return { url, storeId: args.storeId, needsInit: args.handle._tag === 'None', payload }
|
|
166
|
-
}
|
|
167
|
-
|
|
168
110
|
export interface SyncBackendOptions {
|
|
169
111
|
/**
|
|
170
112
|
* The endpoint to pull/push events. Pull is a `GET` request, push is a `POST` request.
|
|
@@ -179,7 +121,25 @@ export interface SyncBackendOptions {
|
|
|
179
121
|
| {
|
|
180
122
|
push: string
|
|
181
123
|
pull: string
|
|
124
|
+
ping: string
|
|
182
125
|
}
|
|
126
|
+
|
|
127
|
+
ping?: {
|
|
128
|
+
/**
|
|
129
|
+
* @default true
|
|
130
|
+
*/
|
|
131
|
+
enabled?: boolean
|
|
132
|
+
/**
|
|
133
|
+
* How long to wait for a ping response before timing out
|
|
134
|
+
* @default 10 seconds
|
|
135
|
+
*/
|
|
136
|
+
requestTimeout?: Duration.DurationInput
|
|
137
|
+
/**
|
|
138
|
+
* How often to send ping requests
|
|
139
|
+
* @default 10 seconds
|
|
140
|
+
*/
|
|
141
|
+
requestInterval?: Duration.DurationInput
|
|
142
|
+
}
|
|
183
143
|
}
|
|
184
144
|
|
|
185
145
|
export const SyncMetadata = Schema.Struct({
|
|
@@ -188,48 +148,94 @@ export const SyncMetadata = Schema.Struct({
|
|
|
188
148
|
handle: Schema.String,
|
|
189
149
|
})
|
|
190
150
|
|
|
191
|
-
type SyncMetadata =
|
|
192
|
-
offset: string
|
|
193
|
-
// TODO move this into some kind of "global" sync metadata as it's the same for each event
|
|
194
|
-
handle: string
|
|
195
|
-
}
|
|
151
|
+
export type SyncMetadata = typeof SyncMetadata.Type
|
|
196
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
|
+
*/
|
|
197
194
|
export const makeSyncBackend =
|
|
198
|
-
({ endpoint }: SyncBackendOptions): SyncBackendConstructor<SyncMetadata> =>
|
|
195
|
+
({ endpoint, ...options }: SyncBackendOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
|
|
199
196
|
({ storeId, payload }) =>
|
|
200
197
|
Effect.gen(function* () {
|
|
201
|
-
const isConnected = yield* SubscriptionRef.make(
|
|
198
|
+
const isConnected = yield* SubscriptionRef.make(false)
|
|
202
199
|
const pullEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.pull
|
|
203
200
|
const pushEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.push
|
|
201
|
+
const pingEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.ping
|
|
202
|
+
|
|
203
|
+
const httpClient = yield* HttpClient.HttpClient
|
|
204
204
|
|
|
205
|
-
const
|
|
205
|
+
const runPull = (
|
|
206
206
|
handle: Option.Option<SyncMetadata>,
|
|
207
|
+
{ live }: { live: boolean },
|
|
207
208
|
): Effect.Effect<
|
|
208
209
|
Option.Option<
|
|
209
210
|
readonly [
|
|
210
|
-
|
|
211
|
+
/** The batch of events */
|
|
212
|
+
ReadonlyArray<{
|
|
211
213
|
metadata: Option.Option<SyncMetadata>
|
|
212
|
-
eventEncoded: LiveStoreEvent.
|
|
214
|
+
eventEncoded: LiveStoreEvent.Global.Encoded
|
|
213
215
|
}>,
|
|
216
|
+
/** The next handle to use for the next pull */
|
|
214
217
|
Option.Option<SyncMetadata>,
|
|
215
218
|
]
|
|
216
219
|
>,
|
|
217
|
-
InvalidPullError | IsOfflineError
|
|
218
|
-
HttpClient.HttpClient
|
|
220
|
+
InvalidPullError | IsOfflineError
|
|
219
221
|
> =>
|
|
220
222
|
Effect.gen(function* () {
|
|
221
|
-
const argsJson = yield* Schema.encode(
|
|
222
|
-
ApiSchema.PullPayload.make({ storeId, handle, payload }),
|
|
223
|
+
const argsJson = yield* Schema.encode(ApiSchema.ArgsSchema)(
|
|
224
|
+
ApiSchema.PullPayload.make({ storeId, handle, payload, live }),
|
|
223
225
|
)
|
|
224
226
|
const url = `${pullEndpoint}?args=${argsJson}`
|
|
225
227
|
|
|
226
|
-
const resp = yield*
|
|
228
|
+
const resp = yield* httpClient.get(url)
|
|
227
229
|
|
|
228
230
|
if (resp.status === 401) {
|
|
229
231
|
const body = yield* resp.text.pipe(Effect.catchAll(() => Effect.succeed('-')))
|
|
230
232
|
return yield* InvalidPullError.make({
|
|
231
|
-
|
|
233
|
+
cause: new Error(`Unauthorized (401): Couldn't connect to ElectricSQL: ${body}`),
|
|
232
234
|
})
|
|
235
|
+
} else if (resp.status === 400) {
|
|
236
|
+
// Electric returns 400 when table doesn't exist
|
|
237
|
+
// Return empty result for non-existent tables
|
|
238
|
+
return Option.some([[], Option.none()] as const)
|
|
233
239
|
} else if (resp.status === 409) {
|
|
234
240
|
// https://electric-sql.com/openapi.html#/paths/~1v1~1shape/get
|
|
235
241
|
// {
|
|
@@ -243,8 +249,9 @@ export const makeSyncBackend =
|
|
|
243
249
|
// until we found a new event, then, continue with the new handle
|
|
244
250
|
return notYetImplemented(`Electric shape not found`)
|
|
245
251
|
} else if (resp.status < 200 || resp.status >= 300) {
|
|
252
|
+
const body = yield* resp.text
|
|
246
253
|
return yield* InvalidPullError.make({
|
|
247
|
-
|
|
254
|
+
cause: new Error(`Unexpected status code: ${resp.status}: ${body}`),
|
|
248
255
|
})
|
|
249
256
|
}
|
|
250
257
|
|
|
@@ -257,56 +264,105 @@ export const makeSyncBackend =
|
|
|
257
264
|
// Electric completes the long-poll request after ~20 seconds with a 204 status
|
|
258
265
|
// In this case we just retry where we left off
|
|
259
266
|
if (resp.status === 204) {
|
|
260
|
-
return Option.some([
|
|
267
|
+
return Option.some([[], Option.some(nextHandle)] as const)
|
|
261
268
|
}
|
|
262
269
|
|
|
263
|
-
const
|
|
270
|
+
const allItems = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem), {
|
|
264
271
|
onExcessProperty: 'preserve',
|
|
265
272
|
})(resp)
|
|
266
273
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
.
|
|
270
|
-
|
|
271
|
-
eventEncoded: item.value! as LiveStoreEvent.AnyEncodedGlobal,
|
|
272
|
-
}))
|
|
274
|
+
// Check for delete/update operations and throw descriptive error
|
|
275
|
+
const invalidOperations = ReadonlyArray.filterMap(allItems, (item) =>
|
|
276
|
+
Schema.is(ResponseItemInvalid)(item) ? Option.some(item.headers.operation) : Option.none(),
|
|
277
|
+
)
|
|
273
278
|
|
|
274
|
-
|
|
275
|
-
|
|
279
|
+
if (invalidOperations.length > 0) {
|
|
280
|
+
const operation = invalidOperations[0]!
|
|
281
|
+
return yield* new InvalidOperationError({
|
|
282
|
+
operation,
|
|
283
|
+
message: `ElectricSQL '${operation}' event received. This results from directly mutating the event log. Append a series of events that produce the desired state instead of mutating the event log.`,
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const items = allItems.filter(Schema.is(ResponseItemInsert)).map((item) => ({
|
|
288
|
+
metadata: Option.some({ offset: nextHandle.offset, handle: nextHandle.handle }),
|
|
289
|
+
eventEncoded: item.value as LiveStoreEvent.Global.Encoded,
|
|
290
|
+
}))
|
|
276
291
|
|
|
277
|
-
|
|
278
|
-
// return Option.none()
|
|
279
|
-
// }
|
|
292
|
+
yield* Effect.annotateCurrentSpan({ itemsCount: items.length, nextHandle })
|
|
280
293
|
|
|
281
|
-
return Option.some([
|
|
294
|
+
return Option.some([items, Option.some(nextHandle)] as const)
|
|
282
295
|
}).pipe(
|
|
283
296
|
Effect.scoped,
|
|
284
|
-
Effect.mapError((cause) =>
|
|
285
|
-
|
|
286
|
-
),
|
|
297
|
+
Effect.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
|
|
298
|
+
Effect.withSpan('electric-provider:runPull', { attributes: { handle, live } }),
|
|
287
299
|
)
|
|
288
300
|
|
|
289
301
|
const pullEndpointHasSameOrigin =
|
|
290
302
|
pullEndpoint.startsWith('/') ||
|
|
291
303
|
(globalThis.location !== undefined && globalThis.location.origin === new URL(pullEndpoint).origin)
|
|
292
304
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
305
|
+
const pingTimeout = options.ping?.requestTimeout ?? 10_000
|
|
306
|
+
|
|
307
|
+
const ping: SyncBackend.SyncBackend<SyncMetadata>['ping'] = Effect.gen(function* () {
|
|
308
|
+
yield* httpClient.pipe(HttpClient.filterStatusOk).head(pingEndpoint)
|
|
309
|
+
|
|
310
|
+
yield* SubscriptionRef.set(isConnected, true)
|
|
311
|
+
}).pipe(
|
|
312
|
+
UnknownError.mapToUnknownError,
|
|
313
|
+
Effect.timeout(pingTimeout),
|
|
314
|
+
Effect.catchTag('TimeoutException', () => SubscriptionRef.set(isConnected, false)),
|
|
315
|
+
Effect.withSpan('electric-provider:ping'),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
const pingInterval = options.ping?.requestInterval ?? 10_000
|
|
319
|
+
|
|
320
|
+
if (options.ping?.enabled !== false) {
|
|
321
|
+
// Automatically ping the server to keep the connection alive
|
|
322
|
+
yield* ping.pipe(Effect.repeat(Schedule.spaced(pingInterval)), Effect.tapCauseLogPretty, Effect.forkScoped)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// If the pull endpoint has the same origin as the current page, we can assume that we already have a connection
|
|
326
|
+
// otherwise we send a HEAD request to speed up the connection process
|
|
327
|
+
const connect: SyncBackend.SyncBackend<SyncMetadata>['connect'] = pullEndpointHasSameOrigin
|
|
328
|
+
? Effect.void
|
|
329
|
+
: ping.pipe(UnknownError.mapToUnknownError)
|
|
330
|
+
|
|
331
|
+
return SyncBackend.of({
|
|
332
|
+
connect,
|
|
333
|
+
pull: (cursor, options) => {
|
|
334
|
+
let hasEmittedAtLeastOnce = false
|
|
335
|
+
|
|
336
|
+
return Stream.unfoldEffect(cursor.pipe(Option.flatMap((_) => _.metadata)), (metadataOption) =>
|
|
337
|
+
Effect.gen(function* () {
|
|
338
|
+
const result = yield* runPull(metadataOption, { live: options?.live ?? false })
|
|
339
|
+
if (Option.isNone(result)) return Option.none()
|
|
340
|
+
|
|
341
|
+
const [batch, nextMetadataOption] = result.value
|
|
342
|
+
|
|
343
|
+
// Continue pagination if we have data
|
|
344
|
+
if (batch.length > 0) {
|
|
345
|
+
hasEmittedAtLeastOnce = true
|
|
346
|
+
return Option.some([{ batch, hasMore: true }, nextMetadataOption])
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Make sure we emit at least once even if there's no data or we're live-pulling
|
|
350
|
+
if (hasEmittedAtLeastOnce === false || options?.live) {
|
|
351
|
+
hasEmittedAtLeastOnce = true
|
|
352
|
+
return Option.some([{ batch, hasMore: false }, nextMetadataOption])
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Stop on empty batch (when not live)
|
|
356
|
+
return Option.none()
|
|
357
|
+
}),
|
|
306
358
|
).pipe(
|
|
307
|
-
Stream.
|
|
308
|
-
|
|
309
|
-
|
|
359
|
+
Stream.map(({ batch, hasMore }) => ({
|
|
360
|
+
batch,
|
|
361
|
+
pageInfo: hasMore ? SyncBackend.pageInfoMoreUnknown : SyncBackend.pageInfoNoMore,
|
|
362
|
+
})),
|
|
363
|
+
Stream.withSpan('electric-provider:pull'),
|
|
364
|
+
)
|
|
365
|
+
},
|
|
310
366
|
|
|
311
367
|
push: (batch) =>
|
|
312
368
|
Effect.gen(function* () {
|
|
@@ -314,18 +370,17 @@ export const makeSyncBackend =
|
|
|
314
370
|
HttpClientRequest.post(pushEndpoint),
|
|
315
371
|
ApiSchema.PushPayload.make({ storeId, batch }),
|
|
316
372
|
).pipe(
|
|
317
|
-
Effect.andThen(HttpClient.execute),
|
|
373
|
+
Effect.andThen(httpClient.pipe(HttpClient.filterStatusOk).execute),
|
|
318
374
|
Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
|
|
319
375
|
Effect.scoped,
|
|
320
|
-
Effect.mapError((cause) =>
|
|
321
|
-
InvalidPushError.make({ reason: { _tag: 'Unexpected', message: cause.toString() } }),
|
|
322
|
-
),
|
|
376
|
+
Effect.mapError((cause) => InvalidPushError.make({ cause: UnknownError.make({ cause }) })),
|
|
323
377
|
)
|
|
324
378
|
|
|
325
379
|
if (!resp.success) {
|
|
326
|
-
yield* InvalidPushError.make({
|
|
380
|
+
return yield* InvalidPushError.make({ cause: new UnknownError({ cause: new Error('Push failed') }) })
|
|
327
381
|
}
|
|
328
|
-
}),
|
|
382
|
+
}).pipe(Effect.withSpan('electric-provider:push')),
|
|
383
|
+
ping,
|
|
329
384
|
isConnected,
|
|
330
385
|
metadata: {
|
|
331
386
|
name: '@livestore/sync-electric',
|
|
@@ -333,17 +388,11 @@ export const makeSyncBackend =
|
|
|
333
388
|
protocol: 'http',
|
|
334
389
|
endpoint,
|
|
335
390
|
},
|
|
336
|
-
|
|
391
|
+
supports: {
|
|
392
|
+
// Given Electric is heavily optimized for immutable caching, we can't know the remaining count
|
|
393
|
+
// until we've reached the end of the stream
|
|
394
|
+
pullPageInfoKnown: false,
|
|
395
|
+
pullLive: true,
|
|
396
|
+
},
|
|
397
|
+
})
|
|
337
398
|
})
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Needs to be bumped when the storage format changes (e.g. eventlogTable schema changes)
|
|
341
|
-
*
|
|
342
|
-
* Changing this version number will lead to a "soft reset".
|
|
343
|
-
*/
|
|
344
|
-
export const PERSISTENCE_FORMAT_VERSION = 6
|
|
345
|
-
|
|
346
|
-
export const toTableName = (storeId: string) => {
|
|
347
|
-
const escapedStoreId = storeId.replaceAll(/[^a-zA-Z0-9_]/g, '_')
|
|
348
|
-
return `eventlog_${PERSISTENCE_FORMAT_VERSION}_${escapedStoreId}`
|
|
349
|
-
}
|