@livestore/sync-cf 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/README.md +7 -8
- package/dist/.tsbuildinfo +1 -1
- package/dist/cf-worker/do/durable-object.d.ts +1 -1
- package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
- package/dist/cf-worker/do/durable-object.js +36 -18
- package/dist/cf-worker/do/durable-object.js.map +1 -1
- package/dist/cf-worker/do/layer.d.ts +24 -20
- package/dist/cf-worker/do/layer.d.ts.map +1 -1
- package/dist/cf-worker/do/layer.js +57 -24
- package/dist/cf-worker/do/layer.js.map +1 -1
- package/dist/cf-worker/do/pull.d.ts +8 -3
- package/dist/cf-worker/do/pull.d.ts.map +1 -1
- package/dist/cf-worker/do/pull.js +26 -21
- package/dist/cf-worker/do/pull.js.map +1 -1
- package/dist/cf-worker/do/push.d.ts +20 -6
- package/dist/cf-worker/do/push.d.ts.map +1 -1
- package/dist/cf-worker/do/push.js +84 -56
- package/dist/cf-worker/do/push.js.map +1 -1
- package/dist/cf-worker/do/push.test.d.ts +2 -0
- package/dist/cf-worker/do/push.test.d.ts.map +1 -0
- package/dist/cf-worker/do/push.test.js +30 -0
- package/dist/cf-worker/do/push.test.js.map +1 -0
- package/dist/cf-worker/do/sqlite.d.ts +49 -52
- package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
- package/dist/cf-worker/do/sqlite.js +14 -5
- package/dist/cf-worker/do/sqlite.js.map +1 -1
- package/dist/cf-worker/do/sync-storage.d.ts +15 -9
- package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
- package/dist/cf-worker/do/sync-storage.js +111 -25
- package/dist/cf-worker/do/sync-storage.js.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts +2 -2
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.js +31 -23
- package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts +5 -3
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/http-rpc-server.js +28 -19
- package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +3 -2
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.js +32 -10
- package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
- package/dist/cf-worker/shared.d.ts +124 -34
- package/dist/cf-worker/shared.d.ts.map +1 -1
- package/dist/cf-worker/shared.js +52 -17
- package/dist/cf-worker/shared.js.map +1 -1
- package/dist/cf-worker/worker.d.ts +47 -39
- package/dist/cf-worker/worker.d.ts.map +1 -1
- package/dist/cf-worker/worker.js +54 -35
- package/dist/cf-worker/worker.js.map +1 -1
- package/dist/client/transport/do-rpc-client.d.ts +13 -4
- package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/do-rpc-client.js +74 -28
- package/dist/client/transport/do-rpc-client.js.map +1 -1
- package/dist/client/transport/http-rpc-client.d.ts +3 -3
- package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/http-rpc-client.js +51 -22
- package/dist/client/transport/http-rpc-client.js.map +1 -1
- package/dist/client/transport/ws-rpc-client.d.ts +4 -3
- package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/ws-rpc-client.js +40 -22
- package/dist/client/transport/ws-rpc-client.js.map +1 -1
- package/dist/common/constants.d.ts +7 -0
- package/dist/common/constants.d.ts.map +1 -0
- package/dist/common/constants.js +17 -0
- package/dist/common/constants.js.map +1 -0
- package/dist/common/do-rpc-schema.d.ts +42 -43
- package/dist/common/do-rpc-schema.d.ts.map +1 -1
- package/dist/common/do-rpc-schema.js +5 -5
- package/dist/common/do-rpc-schema.js.map +1 -1
- package/dist/common/http-rpc-schema.d.ts +37 -38
- package/dist/common/http-rpc-schema.d.ts.map +1 -1
- package/dist/common/http-rpc-schema.js +7 -7
- package/dist/common/http-rpc-schema.js.map +1 -1
- package/dist/common/mod.d.ts +5 -3
- package/dist/common/mod.d.ts.map +1 -1
- package/dist/common/mod.js +4 -2
- package/dist/common/mod.js.map +1 -1
- package/dist/common/sync-message-types.d.ts +113 -130
- package/dist/common/sync-message-types.d.ts.map +1 -1
- package/dist/common/sync-message-types.js +29 -17
- package/dist/common/sync-message-types.js.map +1 -1
- package/dist/common/ws-rpc-schema.d.ts +34 -35
- package/dist/common/ws-rpc-schema.d.ts.map +1 -1
- package/dist/common/ws-rpc-schema.js +5 -5
- package/dist/common/ws-rpc-schema.js.map +1 -1
- package/package.json +57 -15
- package/src/cf-worker/do/durable-object.ts +59 -41
- package/src/cf-worker/do/layer.ts +153 -88
- package/src/cf-worker/do/pull.ts +48 -31
- package/src/cf-worker/do/push.test.ts +40 -0
- package/src/cf-worker/do/push.ts +163 -125
- package/src/cf-worker/do/sqlite.ts +15 -5
- package/src/cf-worker/do/sync-storage.ts +171 -38
- package/src/cf-worker/do/transport/do-rpc-server.ts +40 -40
- package/src/cf-worker/do/transport/http-rpc-server.ts +37 -20
- package/src/cf-worker/do/transport/ws-rpc-server.ts +44 -16
- package/src/cf-worker/shared.ts +152 -36
- package/src/cf-worker/worker.ts +110 -58
- package/src/client/transport/do-rpc-client.ts +106 -40
- package/src/client/transport/http-rpc-client.ts +74 -38
- package/src/client/transport/ws-rpc-client.ts +55 -29
- package/src/common/constants.ts +18 -0
- package/src/common/do-rpc-schema.ts +6 -5
- package/src/common/http-rpc-schema.ts +8 -7
- package/src/common/mod.ts +4 -3
- package/src/common/sync-message-types.ts +19 -19
- package/src/common/ws-rpc-schema.ts +6 -5
- package/dist/cf-worker/do/ws-chunking.d.ts +0 -22
- package/dist/cf-worker/do/ws-chunking.d.ts.map +0 -1
- package/dist/cf-worker/do/ws-chunking.js +0 -49
- package/dist/cf-worker/do/ws-chunking.js.map +0 -1
- package/src/cf-worker/do/ws-chunking.ts +0 -76
|
@@ -1,37 +1,40 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { LiveStoreEvent } from '@livestore/common/schema'
|
|
1
|
+
import { UnknownError } from '@livestore/common'
|
|
3
2
|
import type { CfTypes } from '@livestore/common-cf'
|
|
4
|
-
import {
|
|
3
|
+
import type { LiveStoreEvent } from '@livestore/common/schema'
|
|
4
|
+
import { Effect, Option, ReadonlyArray as EffectArray, Schema, Stream } from '@livestore/utils/effect'
|
|
5
|
+
|
|
5
6
|
import { SyncMetadata } from '../../common/sync-message-types.ts'
|
|
6
|
-
import {
|
|
7
|
+
import { PERSISTENCE_FORMAT_VERSION, type StoreId } from '../shared.ts'
|
|
7
8
|
import { eventlogTable } from './sqlite.ts'
|
|
8
9
|
|
|
10
|
+
export type StorageEngine = { _tag: 'd1'; db: CfTypes.D1Database } | { _tag: 'do-sqlite' }
|
|
11
|
+
|
|
9
12
|
export type SyncStorage = {
|
|
10
13
|
dbName: string
|
|
11
14
|
getEvents: (cursor: number | undefined) => Effect.Effect<
|
|
12
15
|
{
|
|
13
16
|
total: number
|
|
14
17
|
stream: Stream.Stream<
|
|
15
|
-
{ eventEncoded: LiveStoreEvent.
|
|
16
|
-
|
|
18
|
+
{ eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> },
|
|
19
|
+
UnknownError
|
|
17
20
|
>
|
|
18
21
|
},
|
|
19
|
-
|
|
22
|
+
UnknownError
|
|
20
23
|
>
|
|
21
24
|
appendEvents: (
|
|
22
|
-
batch: ReadonlyArray<LiveStoreEvent.
|
|
25
|
+
batch: ReadonlyArray<LiveStoreEvent.Global.Encoded>,
|
|
23
26
|
createdAt: string,
|
|
24
|
-
) => Effect.Effect<void,
|
|
25
|
-
resetStore: Effect.Effect<void,
|
|
27
|
+
) => Effect.Effect<void, UnknownError>
|
|
28
|
+
resetStore: Effect.Effect<void, UnknownError>
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
export const makeStorage = (ctx: CfTypes.DurableObjectState,
|
|
31
|
+
export const makeStorage = (ctx: CfTypes.DurableObjectState, storeId: StoreId, engine: StorageEngine): SyncStorage => {
|
|
29
32
|
const dbName = `eventlog_${PERSISTENCE_FORMAT_VERSION}_${toValidTableName(storeId)}`
|
|
30
33
|
|
|
31
34
|
const execDb = <T>(cb: (db: CfTypes.D1Database) => Promise<CfTypes.D1Result<T>>) =>
|
|
32
35
|
Effect.tryPromise({
|
|
33
|
-
try: () => cb(
|
|
34
|
-
catch: (error) => new
|
|
36
|
+
try: () => cb(engine._tag === 'd1' ? engine.db : (undefined as never)),
|
|
37
|
+
catch: (error) => new UnknownError({ cause: error, payload: { dbName } }),
|
|
35
38
|
}).pipe(
|
|
36
39
|
Effect.map((_) => _.results),
|
|
37
40
|
Effect.withSpan('@livestore/sync-cf:durable-object:execDb'),
|
|
@@ -47,6 +50,7 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
|
|
|
47
50
|
const D1_MIN_PAGE_SIZE = 1
|
|
48
51
|
|
|
49
52
|
const decodeEventlogRows = Schema.decodeUnknownSync(Schema.Array(eventlogTable.rowSchema))
|
|
53
|
+
const jsonStringify = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))
|
|
50
54
|
const textEncoder = new TextEncoder()
|
|
51
55
|
|
|
52
56
|
const decreaseLimit = (limit: number) => Math.max(D1_MIN_PAGE_SIZE, Math.floor(limit / 2))
|
|
@@ -66,17 +70,17 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
|
|
|
66
70
|
return limit
|
|
67
71
|
}
|
|
68
72
|
|
|
69
|
-
const
|
|
73
|
+
const getEventsD1 = (
|
|
70
74
|
cursor: number | undefined,
|
|
71
75
|
): Effect.Effect<
|
|
72
76
|
{
|
|
73
77
|
total: number
|
|
74
78
|
stream: Stream.Stream<
|
|
75
|
-
{ eventEncoded: LiveStoreEvent.
|
|
76
|
-
|
|
79
|
+
{ eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> },
|
|
80
|
+
UnknownError
|
|
77
81
|
>
|
|
78
82
|
},
|
|
79
|
-
|
|
83
|
+
UnknownError
|
|
80
84
|
> =>
|
|
81
85
|
Effect.gen(function* () {
|
|
82
86
|
const countStatement =
|
|
@@ -92,13 +96,13 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
|
|
|
92
96
|
const total = Number(countRows[0]?.total ?? 0)
|
|
93
97
|
|
|
94
98
|
type State = { cursor: number | undefined; limit: number }
|
|
95
|
-
type EmittedEvent = { eventEncoded: LiveStoreEvent.
|
|
99
|
+
type EmittedEvent = { eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> }
|
|
96
100
|
|
|
97
101
|
const initialState: State = { cursor, limit: D1_INITIAL_PAGE_SIZE }
|
|
98
102
|
|
|
99
103
|
const fetchPage = (
|
|
100
104
|
state: State,
|
|
101
|
-
): Effect.Effect<
|
|
105
|
+
): Effect.Effect<readonly [ReadonlyArray<EmittedEvent>, Option.Option<State>], UnknownError> =>
|
|
102
106
|
Effect.gen(function* () {
|
|
103
107
|
const statement =
|
|
104
108
|
state.cursor === undefined
|
|
@@ -113,10 +117,11 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
|
|
|
113
117
|
})
|
|
114
118
|
|
|
115
119
|
if (rawEvents.length === 0) {
|
|
116
|
-
|
|
120
|
+
const done: readonly [ReadonlyArray<EmittedEvent>, Option.Option<State>] = [[], Option.none()]
|
|
121
|
+
return done
|
|
117
122
|
}
|
|
118
123
|
|
|
119
|
-
const encodedSize = textEncoder.encode(
|
|
124
|
+
const encodedSize = textEncoder.encode(jsonStringify(rawEvents)).byteLength
|
|
120
125
|
|
|
121
126
|
if (encodedSize > D1_TARGET_RESPONSE_BYTES && state.limit > D1_MIN_PAGE_SIZE) {
|
|
122
127
|
const nextLimit = decreaseLimit(state.limit)
|
|
@@ -126,28 +131,36 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
|
|
|
126
131
|
}
|
|
127
132
|
}
|
|
128
133
|
|
|
129
|
-
const decodedRows =
|
|
134
|
+
const decodedRows = decodeEventlogRows(rawEvents)
|
|
135
|
+
|
|
136
|
+
if (EffectArray.isReadonlyArrayNonEmpty(decodedRows) === false) {
|
|
137
|
+
const done: readonly [ReadonlyArray<EmittedEvent>, Option.Option<State>] = [[], Option.none()]
|
|
138
|
+
return done
|
|
139
|
+
}
|
|
130
140
|
|
|
131
|
-
const
|
|
141
|
+
const events = decodedRows.map(({ createdAt, ...eventEncoded }) => ({
|
|
132
142
|
eventEncoded,
|
|
133
143
|
metadata: Option.some(SyncMetadata.make({ createdAt })),
|
|
134
144
|
}))
|
|
135
145
|
|
|
136
|
-
const lastSeqNum =
|
|
146
|
+
const lastSeqNum = EffectArray.lastNonEmpty(decodedRows).seqNum
|
|
137
147
|
const nextState: State = { cursor: lastSeqNum, limit: computeNextLimit(state.limit, encodedSize) }
|
|
138
148
|
|
|
139
|
-
|
|
149
|
+
const page: readonly [ReadonlyArray<EmittedEvent>, Option.Option<State>] = [events, Option.some(nextState)]
|
|
150
|
+
return page
|
|
140
151
|
})
|
|
141
152
|
|
|
142
|
-
const stream = Stream.
|
|
153
|
+
const stream = Stream.paginate(initialState, fetchPage)
|
|
143
154
|
|
|
144
155
|
return { total, stream }
|
|
145
156
|
}).pipe(
|
|
146
|
-
|
|
147
|
-
Effect.withSpan('@livestore/sync-cf:durable-object:getEvents', {
|
|
157
|
+
UnknownError.mapToUnknownError,
|
|
158
|
+
Effect.withSpan('@livestore/sync-cf:durable-object:getEvents', {
|
|
159
|
+
attributes: { dbName, cursor, engine: engine._tag },
|
|
160
|
+
}),
|
|
148
161
|
)
|
|
149
162
|
|
|
150
|
-
const
|
|
163
|
+
const appendEventsD1: SyncStorage['appendEvents'] = (batch, createdAt) =>
|
|
151
164
|
Effect.gen(function* () {
|
|
152
165
|
// If there are no events, do nothing.
|
|
153
166
|
if (batch.length === 0) return
|
|
@@ -182,24 +195,144 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
|
|
|
182
195
|
)
|
|
183
196
|
}
|
|
184
197
|
}).pipe(
|
|
185
|
-
|
|
198
|
+
UnknownError.mapToUnknownError,
|
|
186
199
|
Effect.withSpan('@livestore/sync-cf:durable-object:appendEvents', {
|
|
187
|
-
attributes: { dbName, batchLength: batch.length },
|
|
200
|
+
attributes: { dbName, batchLength: batch.length, engine: engine._tag },
|
|
188
201
|
}),
|
|
189
202
|
)
|
|
190
203
|
|
|
191
204
|
const resetStore = Effect.promise(() => ctx.storage.deleteAll()).pipe(
|
|
192
|
-
|
|
205
|
+
UnknownError.mapToUnknownError,
|
|
193
206
|
Effect.withSpan('@livestore/sync-cf:durable-object:resetStore'),
|
|
194
207
|
)
|
|
195
208
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
209
|
+
// DO SQLite engine implementation
|
|
210
|
+
const getEventsDoSqlite = (
|
|
211
|
+
cursor: number | undefined,
|
|
212
|
+
): Effect.Effect<
|
|
213
|
+
{
|
|
214
|
+
total: number
|
|
215
|
+
stream: Stream.Stream<
|
|
216
|
+
{ eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> },
|
|
217
|
+
UnknownError
|
|
218
|
+
>
|
|
219
|
+
},
|
|
220
|
+
UnknownError
|
|
221
|
+
> =>
|
|
222
|
+
Effect.gen(function* () {
|
|
223
|
+
const selectCountSql =
|
|
224
|
+
cursor === undefined
|
|
225
|
+
? `SELECT COUNT(*) as total FROM "${dbName}"`
|
|
226
|
+
: `SELECT COUNT(*) as total FROM "${dbName}" WHERE seqNum > ?`
|
|
227
|
+
|
|
228
|
+
const total = yield* Effect.try({
|
|
229
|
+
try: () => {
|
|
230
|
+
const cursorIter =
|
|
231
|
+
cursor === undefined ? ctx.storage.sql.exec(selectCountSql) : ctx.storage.sql.exec(selectCountSql, cursor)
|
|
232
|
+
let computed = 0
|
|
233
|
+
for (const row of cursorIter) {
|
|
234
|
+
computed = Number((row as any).total ?? 0)
|
|
235
|
+
}
|
|
236
|
+
return computed
|
|
237
|
+
},
|
|
238
|
+
catch: (error) => new UnknownError({ cause: error, payload: { dbName, stage: 'count' } }),
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
type State = { cursor: number | undefined }
|
|
242
|
+
type EmittedEvent = { eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> }
|
|
243
|
+
|
|
244
|
+
const DO_PAGE_SIZE = 256
|
|
245
|
+
const initialState: State = { cursor }
|
|
246
|
+
|
|
247
|
+
const fetchPage = (
|
|
248
|
+
state: State,
|
|
249
|
+
): Effect.Effect<readonly [ReadonlyArray<EmittedEvent>, Option.Option<State>], UnknownError> =>
|
|
250
|
+
Effect.try({
|
|
251
|
+
try: () => {
|
|
252
|
+
const sql =
|
|
253
|
+
state.cursor === undefined
|
|
254
|
+
? `SELECT * FROM "${dbName}" ORDER BY seqNum ASC LIMIT ?`
|
|
255
|
+
: `SELECT * FROM "${dbName}" WHERE seqNum > ? ORDER BY seqNum ASC LIMIT ?`
|
|
256
|
+
|
|
257
|
+
const iter =
|
|
258
|
+
state.cursor === undefined
|
|
259
|
+
? ctx.storage.sql.exec(sql, DO_PAGE_SIZE)
|
|
260
|
+
: ctx.storage.sql.exec(sql, state.cursor, DO_PAGE_SIZE)
|
|
261
|
+
|
|
262
|
+
const rows: any[] = []
|
|
263
|
+
for (const row of iter) rows.push(row)
|
|
264
|
+
|
|
265
|
+
if (rows.length === 0) {
|
|
266
|
+
const done: readonly [ReadonlyArray<EmittedEvent>, Option.Option<State>] = [[], Option.none()]
|
|
267
|
+
return done
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const decodedRows = decodeEventlogRows(rows)
|
|
271
|
+
|
|
272
|
+
if (EffectArray.isReadonlyArrayNonEmpty(decodedRows) === false) {
|
|
273
|
+
const done: readonly [ReadonlyArray<EmittedEvent>, Option.Option<State>] = [[], Option.none()]
|
|
274
|
+
return done
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const events = decodedRows.map(({ createdAt, ...eventEncoded }) => ({
|
|
278
|
+
eventEncoded,
|
|
279
|
+
metadata: Option.some(SyncMetadata.make({ createdAt })),
|
|
280
|
+
}))
|
|
281
|
+
|
|
282
|
+
const lastSeqNum = EffectArray.lastNonEmpty(decodedRows).seqNum
|
|
283
|
+
const nextState: State = { cursor: lastSeqNum }
|
|
284
|
+
|
|
285
|
+
const page: readonly [ReadonlyArray<EmittedEvent>, Option.Option<State>] = [events, Option.some(nextState)]
|
|
286
|
+
return page
|
|
287
|
+
},
|
|
288
|
+
catch: (error) => new UnknownError({ cause: error, payload: { dbName, stage: 'select' } }),
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
const stream = Stream.paginate(initialState, fetchPage)
|
|
292
|
+
|
|
293
|
+
return { total, stream }
|
|
294
|
+
}).pipe(
|
|
295
|
+
UnknownError.mapToUnknownError,
|
|
296
|
+
Effect.withSpan('@livestore/sync-cf:durable-object:getEvents', {
|
|
297
|
+
attributes: { dbName, cursor, engine: engine._tag },
|
|
298
|
+
}),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
const appendEventsDoSqlite: SyncStorage['appendEvents'] = (batch, createdAt) =>
|
|
302
|
+
Effect.try({
|
|
303
|
+
try: () => {
|
|
304
|
+
if (batch.length === 0) return
|
|
305
|
+
// Keep params per statement within conservative limits (align with D1 bound params ~100)
|
|
306
|
+
const CHUNK_SIZE = 14
|
|
307
|
+
for (let i = 0; i < batch.length; i += CHUNK_SIZE) {
|
|
308
|
+
const chunk = batch.slice(i, i + CHUNK_SIZE)
|
|
309
|
+
const placeholders = chunk.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')
|
|
310
|
+
const sql = `INSERT INTO "${dbName}" (seqNum, parentSeqNum, args, name, createdAt, clientId, sessionId) VALUES ${placeholders}`
|
|
311
|
+
const params = chunk.flatMap((event) => [
|
|
312
|
+
event.seqNum,
|
|
313
|
+
event.parentSeqNum,
|
|
314
|
+
event.args === undefined ? null : JSON.stringify(event.args),
|
|
315
|
+
event.name,
|
|
316
|
+
createdAt,
|
|
317
|
+
event.clientId,
|
|
318
|
+
event.sessionId,
|
|
319
|
+
])
|
|
320
|
+
ctx.storage.sql.exec(sql, ...params)
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
catch: (error) => new UnknownError({ cause: error, payload: { dbName, stage: 'insert' } }),
|
|
324
|
+
}).pipe(
|
|
325
|
+
Effect.withSpan('@livestore/sync-cf:durable-object:appendEvents', {
|
|
326
|
+
attributes: { dbName, batchLength: batch.length, engine: engine._tag },
|
|
327
|
+
}),
|
|
328
|
+
UnknownError.mapToUnknownError,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
if (engine._tag === 'd1') {
|
|
332
|
+
return { dbName, getEvents: getEventsD1, appendEvents: appendEventsD1, resetStore }
|
|
202
333
|
}
|
|
334
|
+
|
|
335
|
+
return { dbName, getEvents: getEventsDoSqlite, appendEvents: appendEventsDoSqlite, resetStore }
|
|
203
336
|
}
|
|
204
337
|
|
|
205
338
|
const toValidTableName = (str: string) => str.replaceAll(/[^a-zA-Z0-9]/g, '_')
|
|
@@ -1,84 +1,84 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { UnknownError } from '@livestore/common'
|
|
2
2
|
import { type CfTypes, toDurableObjectHandler } from '@livestore/common-cf'
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
Headers,
|
|
6
|
-
HttpServer,
|
|
7
|
-
Layer,
|
|
8
|
-
Logger,
|
|
9
|
-
LogLevel,
|
|
10
|
-
Option,
|
|
11
|
-
RpcSerialization,
|
|
12
|
-
Stream,
|
|
13
|
-
} from '@livestore/utils/effect'
|
|
3
|
+
import { Effect, Headers, Option, Stream } from '@livestore/utils/effect'
|
|
4
|
+
|
|
14
5
|
import { SyncDoRpc } from '../../../common/do-rpc-schema.ts'
|
|
15
|
-
import {
|
|
16
|
-
import
|
|
6
|
+
import { rpcSubscriptionKeyPrefix, type RpcSubscription } from '../../shared.ts'
|
|
7
|
+
import * as DoCtx from '../layer.ts'
|
|
17
8
|
import { makeEndingPullStream } from '../pull.ts'
|
|
18
9
|
import { makePush } from '../push.ts'
|
|
19
10
|
|
|
20
11
|
export interface DoRpcHandlerOptions {
|
|
21
12
|
payload: Uint8Array<ArrayBuffer>
|
|
22
|
-
input: Omit<DoCtxInput, 'from'>
|
|
13
|
+
input: Omit<DoCtx.DoCtxInput, 'from'>
|
|
23
14
|
}
|
|
24
15
|
|
|
25
16
|
export const createDoRpcHandler = (
|
|
26
17
|
options: DoRpcHandlerOptions,
|
|
27
18
|
): Effect.Effect<Uint8Array<ArrayBuffer> | CfTypes.ReadableStream> =>
|
|
28
|
-
Effect.gen(this, function* () {
|
|
19
|
+
Effect.gen({ self: this }, function* () {
|
|
29
20
|
const { payload, input } = options
|
|
30
|
-
// const { rpcSubscriptions, backendId, doOptions, ctx, env } = yield* DoCtx
|
|
31
21
|
|
|
32
22
|
// TODO add admin RPCs
|
|
33
23
|
const RpcLive = SyncDoRpc.toLayer({
|
|
34
|
-
'SyncDoRpc.Ping': (
|
|
35
|
-
return Effect.succeed(SyncMessage.Pong.make({}))
|
|
36
|
-
},
|
|
24
|
+
'SyncDoRpc.Ping': () => Effect.void,
|
|
37
25
|
'SyncDoRpc.Pull': (req, { headers }) =>
|
|
38
|
-
Effect.gen(this, function* () {
|
|
39
|
-
const {
|
|
26
|
+
Effect.gen({ self: this }, function* () {
|
|
27
|
+
const { ctx } = yield* DoCtx.DoCtx
|
|
40
28
|
|
|
41
29
|
// TODO rename `req.rpcContext` to something more appropriate
|
|
42
|
-
if (req.rpcContext) {
|
|
43
|
-
|
|
30
|
+
if (req.rpcContext !== undefined) {
|
|
31
|
+
const subscription: RpcSubscription = {
|
|
44
32
|
storeId: req.storeId,
|
|
45
|
-
payload: req.payload,
|
|
46
33
|
subscribedAt: Date.now(),
|
|
47
34
|
requestId: Headers.get(headers, 'x-rpc-request-id').pipe(Option.getOrThrow),
|
|
48
35
|
callerContext: req.rpcContext.callerContext,
|
|
49
|
-
|
|
36
|
+
...(req.payload !== undefined ? { payload: req.payload } : {}),
|
|
37
|
+
}
|
|
38
|
+
// Keyed by client DO id (not storeId — one backend serves many client DOs). The DO's synchronous KV
|
|
39
|
+
// storage is the single source of truth, so the entry outlives backend reconstruction.
|
|
40
|
+
ctx.storage.kv.put(`${rpcSubscriptionKeyPrefix}${subscription.callerContext.durableObjectId}`, subscription)
|
|
50
41
|
}
|
|
51
42
|
|
|
52
|
-
|
|
43
|
+
// DO-RPC doesn't have HTTP headers context - headers are undefined
|
|
44
|
+
return makeEndingPullStream({ req, payload: req.payload, headers: undefined })
|
|
53
45
|
}).pipe(
|
|
54
46
|
Stream.unwrap,
|
|
55
47
|
Stream.map((res) => ({
|
|
56
48
|
...res,
|
|
57
49
|
rpcRequestId: Headers.get(headers, 'x-rpc-request-id').pipe(Option.getOrThrow),
|
|
58
50
|
})),
|
|
59
|
-
Stream.
|
|
60
|
-
Stream.mapError((cause) =>
|
|
61
|
-
|
|
51
|
+
Stream.provide(DoCtx.layer({ ...input, from: { storeId: req.storeId } })),
|
|
52
|
+
Stream.mapError((cause) =>
|
|
53
|
+
cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
|
|
54
|
+
? cause
|
|
55
|
+
: new UnknownError({ cause }),
|
|
56
|
+
),
|
|
57
|
+
Stream.tapCause(Effect.log),
|
|
62
58
|
),
|
|
63
59
|
'SyncDoRpc.Push': (req) =>
|
|
64
|
-
Effect.gen(this, function* () {
|
|
65
|
-
const { doOptions, ctx, env, storeId } = yield* DoCtx
|
|
66
|
-
|
|
60
|
+
Effect.gen({ self: this }, function* () {
|
|
61
|
+
const { doOptions, ctx, env, storeId } = yield* DoCtx.DoCtx
|
|
62
|
+
// DO-RPC doesn't have HTTP headers context - headers are undefined
|
|
63
|
+
const push = makePush({ storeId, payload: req.payload, headers: undefined, options: doOptions, ctx, env })
|
|
67
64
|
|
|
68
65
|
return yield* push(req)
|
|
69
66
|
}).pipe(
|
|
70
|
-
Effect.provide(DoCtx.
|
|
71
|
-
Effect.mapError((cause) =>
|
|
67
|
+
Effect.provide(DoCtx.layer({ ...input, from: { storeId: req.storeId } })),
|
|
68
|
+
Effect.mapError((cause) =>
|
|
69
|
+
cause._tag === 'UnknownError' ||
|
|
70
|
+
cause._tag === 'ServerAheadError' ||
|
|
71
|
+
cause._tag === 'BackendIdMismatchError'
|
|
72
|
+
? cause
|
|
73
|
+
: new UnknownError({ cause }),
|
|
74
|
+
),
|
|
72
75
|
Effect.tapCauseLogPretty,
|
|
73
76
|
),
|
|
74
77
|
})
|
|
75
78
|
|
|
76
79
|
const handler = toDurableObjectHandler(SyncDoRpc, {
|
|
77
|
-
layer:
|
|
78
|
-
Layer.provide(Logger.consoleWithThread('SyncDo')),
|
|
79
|
-
Layer.provide(Logger.minimumLogLevel(LogLevel.Debug)),
|
|
80
|
-
),
|
|
80
|
+
layer: RpcLive,
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
-
return yield* handler(payload)
|
|
83
|
+
return yield* handler(payload).pipe(Effect.annotateLogs({ thread: 'SyncDo' }))
|
|
84
84
|
}).pipe(Effect.withSpan('createDoRpcHandler'))
|
|
@@ -1,37 +1,54 @@
|
|
|
1
1
|
import type { CfTypes } from '@livestore/common-cf'
|
|
2
|
-
import { Effect,
|
|
2
|
+
import { Effect, HttpEffect, Layer, RpcSerialization, RpcServer } from '@livestore/utils/effect'
|
|
3
|
+
|
|
3
4
|
import { SyncHttpRpc } from '../../../common/http-rpc-schema.ts'
|
|
4
5
|
import * as SyncMessage from '../../../common/sync-message-types.ts'
|
|
5
|
-
import {
|
|
6
|
+
import { headersRecordToMap } from '../../shared.ts'
|
|
7
|
+
import * as DoCtx from '../layer.ts'
|
|
6
8
|
import { makeEndingPullStream } from '../pull.ts'
|
|
7
9
|
import { makePush } from '../push.ts'
|
|
8
10
|
|
|
9
|
-
export const createHttpRpcHandler = (
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
export const createHttpRpcHandler = Effect.fn('createHttpRpcHandler')(function* ({
|
|
12
|
+
request,
|
|
13
|
+
responseHeaders,
|
|
14
|
+
forwardedHeaders,
|
|
15
|
+
}: {
|
|
16
|
+
request: CfTypes.Request
|
|
17
|
+
responseHeaders?: Record<string, string>
|
|
18
|
+
forwardedHeaders?: Record<string, string>
|
|
19
|
+
}) {
|
|
20
|
+
const handlerLayer = createHttpRpcLayer(forwardedHeaders)
|
|
21
|
+
const httpEffect = yield* RpcServer.toHttpEffect(SyncHttpRpc).pipe(Effect.provide(handlerLayer))
|
|
22
|
+
const webHandler = HttpEffect.toWebHandler(httpEffect)
|
|
23
|
+
|
|
24
|
+
const response = yield* Effect.promise(
|
|
25
|
+
() => webHandler(request as TODO as Request) as TODO as Promise<CfTypes.Response>,
|
|
26
|
+
).pipe(Effect.timeout(10000))
|
|
27
|
+
|
|
28
|
+
if (responseHeaders !== undefined) {
|
|
29
|
+
for (const [key, value] of Object.entries(responseHeaders)) {
|
|
30
|
+
response.headers.set(key, value)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return response
|
|
35
|
+
})
|
|
14
36
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
).pipe(Effect.timeout(10000))
|
|
18
|
-
}).pipe(Effect.withSpan('createHttpRpcHandler'))
|
|
37
|
+
const createHttpRpcLayer = (forwardedHeaders: Record<string, string> | undefined) => {
|
|
38
|
+
const headers = headersRecordToMap(forwardedHeaders)
|
|
19
39
|
|
|
20
|
-
const createHttpRpcLayer =
|
|
21
40
|
// TODO implement admin requests
|
|
22
|
-
SyncHttpRpc.toLayer({
|
|
23
|
-
'SyncHttpRpc.Pull': (req) => makeEndingPullStream(req, req.payload),
|
|
41
|
+
return SyncHttpRpc.toLayer({
|
|
42
|
+
'SyncHttpRpc.Pull': (req) => makeEndingPullStream({ req, payload: req.payload, headers }),
|
|
24
43
|
|
|
25
44
|
'SyncHttpRpc.Push': (req) =>
|
|
26
45
|
Effect.gen(function* () {
|
|
27
|
-
const { ctx, env, doOptions, storeId } = yield* DoCtx
|
|
28
|
-
const push = makePush({ payload:
|
|
46
|
+
const { ctx, env, doOptions, storeId } = yield* DoCtx.DoCtx
|
|
47
|
+
const push = makePush({ payload: req.payload, headers, options: doOptions, storeId, ctx, env })
|
|
29
48
|
|
|
30
49
|
return yield* push(req)
|
|
31
50
|
}),
|
|
32
51
|
|
|
33
52
|
'SyncHttpRpc.Ping': () => Effect.succeed(SyncMessage.Pong.make({})),
|
|
34
|
-
}).pipe(
|
|
35
|
-
|
|
36
|
-
Layer.provideMerge(RpcSerialization.layerJson),
|
|
37
|
-
)
|
|
53
|
+
}).pipe(Layer.provideMerge(RpcSerialization.layerJson))
|
|
54
|
+
}
|
|
@@ -1,34 +1,62 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { UnknownError } from '@livestore/common'
|
|
2
|
+
import { WsContext } from '@livestore/common-cf'
|
|
3
|
+
import { Effect, identity, Layer, Result, RpcServer, Schema, Stream } from '@livestore/utils/effect'
|
|
4
|
+
|
|
3
5
|
import { SyncWsRpc } from '../../../common/ws-rpc-schema.ts'
|
|
4
|
-
import {
|
|
6
|
+
import { headersRecordToMap, WebSocketAttachmentSchema } from '../../shared.ts'
|
|
7
|
+
import * as DoCtx from '../layer.ts'
|
|
5
8
|
import { makeEndingPullStream } from '../pull.ts'
|
|
6
9
|
import { makePush } from '../push.ts'
|
|
7
10
|
|
|
8
|
-
export const makeRpcServer = ({ doSelf, doOptions }: Omit<DoCtxInput, 'from'>) => {
|
|
9
|
-
// TODO implement admin requests
|
|
11
|
+
export const makeRpcServer = ({ doSelf, doOptions }: Omit<DoCtx.DoCtxInput, 'from'>) => {
|
|
10
12
|
const handlersLayer = SyncWsRpc.toLayer({
|
|
11
13
|
'SyncWsRpc.Pull': (req) =>
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
req.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
Effect.gen(function* () {
|
|
15
|
+
const headers = yield* getForwardedHeaders
|
|
16
|
+
return makeEndingPullStream({ req, payload: req.payload, headers }).pipe(
|
|
17
|
+
// Needed to keep the stream alive on the client side for phase 2 (i.e. not send the `Exit` stream RPC message)
|
|
18
|
+
req.live === true ? Stream.concat(Stream.never) : identity,
|
|
19
|
+
Stream.provide(DoCtx.layer({ doSelf, doOptions, from: { storeId: req.storeId } })),
|
|
20
|
+
Stream.mapError((cause) =>
|
|
21
|
+
cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
|
|
22
|
+
? cause
|
|
23
|
+
: new UnknownError({ cause }),
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
}).pipe(Stream.unwrap),
|
|
19
27
|
'SyncWsRpc.Push': (req) =>
|
|
20
28
|
Effect.gen(function* () {
|
|
21
|
-
const { doOptions, storeId, ctx, env } = yield* DoCtx
|
|
29
|
+
const { doOptions, storeId, ctx, env } = yield* DoCtx.DoCtx
|
|
30
|
+
const headers = yield* getForwardedHeaders
|
|
22
31
|
|
|
23
|
-
const push = makePush({ options: doOptions, storeId, payload: req.payload, ctx, env })
|
|
32
|
+
const push = makePush({ options: doOptions, storeId, payload: req.payload, headers, ctx, env })
|
|
24
33
|
|
|
25
34
|
return yield* push(req)
|
|
26
35
|
}).pipe(
|
|
27
|
-
Effect.provide(DoCtx.
|
|
28
|
-
Effect.mapError((cause) =>
|
|
36
|
+
Effect.provide(DoCtx.layer({ doSelf, doOptions, from: { storeId: req.storeId } })),
|
|
37
|
+
Effect.mapError((cause) =>
|
|
38
|
+
cause._tag === 'UnknownError' || cause._tag === 'ServerAheadError' || cause._tag === 'BackendIdMismatchError'
|
|
39
|
+
? cause
|
|
40
|
+
: new UnknownError({ cause }),
|
|
41
|
+
),
|
|
29
42
|
Effect.tapCauseLogPretty,
|
|
30
43
|
),
|
|
31
44
|
})
|
|
32
45
|
|
|
33
46
|
return RpcServer.layer(SyncWsRpc).pipe(Layer.provide(handlersLayer))
|
|
34
47
|
}
|
|
48
|
+
|
|
49
|
+
/** Extracts forwarded headers from the WebSocket attachment */
|
|
50
|
+
const getForwardedHeaders = Effect.gen(function* () {
|
|
51
|
+
const { ws } = yield* WsContext
|
|
52
|
+
const attachment = ws.deserializeAttachment()
|
|
53
|
+
const decoded = Schema.decodeUnknownResult(WebSocketAttachmentSchema)(attachment)
|
|
54
|
+
if (Result.isFailure(decoded) === true) {
|
|
55
|
+
yield* Effect.logError('Failed to decode WebSocket attachment for forwarded headers', { error: decoded.failure })
|
|
56
|
+
ws.close(1011, 'invalid-attachment')
|
|
57
|
+
return yield* Effect.die('Invalid WebSocket attachment (headers decode failed)')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const headers = headersRecordToMap(decoded.success.headers)
|
|
61
|
+
return headers
|
|
62
|
+
})
|