@livestore/sync-cf 0.4.0-dev.8 → 0.4.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 +15 -14
- package/dist/cf-worker/do/durable-object.js.map +1 -1
- package/dist/cf-worker/do/layer.d.ts +6 -6
- package/dist/cf-worker/do/layer.d.ts.map +1 -1
- package/dist/cf-worker/do/layer.js +32 -9
- 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 +22 -10
- package/dist/cf-worker/do/pull.js.map +1 -1
- package/dist/cf-worker/do/push.d.ts +5 -4
- package/dist/cf-worker/do/push.d.ts.map +1 -1
- package/dist/cf-worker/do/push.js +80 -41
- package/dist/cf-worker/do/push.js.map +1 -1
- package/dist/cf-worker/do/sqlite.d.ts +10 -1
- package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
- package/dist/cf-worker/do/sqlite.js +13 -4
- package/dist/cf-worker/do/sqlite.js.map +1 -1
- package/dist/cf-worker/do/sync-storage.d.ts +14 -9
- package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
- package/dist/cf-worker/do/sync-storage.js +92 -18
- package/dist/cf-worker/do/sync-storage.js.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts +2 -1
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.js +13 -7
- package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts +3 -1
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/http-rpc-server.js +24 -15
- package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +2 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.js +30 -8
- package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
- package/dist/cf-worker/shared.d.ts +123 -30
- package/dist/cf-worker/shared.d.ts.map +1 -1
- package/dist/cf-worker/shared.js +50 -6
- package/dist/cf-worker/shared.js.map +1 -1
- package/dist/cf-worker/worker.d.ts +64 -71
- package/dist/cf-worker/worker.d.ts.map +1 -1
- package/dist/cf-worker/worker.js +70 -48
- package/dist/cf-worker/worker.js.map +1 -1
- package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/do-rpc-client.js +27 -10
- package/dist/client/transport/do-rpc-client.js.map +1 -1
- package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/http-rpc-client.js +29 -9
- package/dist/client/transport/http-rpc-client.js.map +1 -1
- package/dist/client/transport/ws-rpc-client.d.ts +2 -1
- package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/ws-rpc-client.js +31 -17
- 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 +6 -6
- package/dist/common/do-rpc-schema.d.ts.map +1 -1
- package/dist/common/do-rpc-schema.js +4 -4
- package/dist/common/do-rpc-schema.js.map +1 -1
- package/dist/common/http-rpc-schema.d.ts +4 -4
- package/dist/common/http-rpc-schema.d.ts.map +1 -1
- package/dist/common/http-rpc-schema.js +4 -4
- package/dist/common/http-rpc-schema.js.map +1 -1
- package/dist/common/mod.d.ts +4 -1
- package/dist/common/mod.d.ts.map +1 -1
- package/dist/common/mod.js +4 -1
- package/dist/common/mod.js.map +1 -1
- package/dist/common/sync-message-types.d.ts +2 -2
- package/dist/common/sync-message-types.js +3 -3
- package/dist/common/sync-message-types.js.map +1 -1
- package/dist/common/ws-rpc-schema.d.ts +3 -3
- package/dist/common/ws-rpc-schema.d.ts.map +1 -1
- package/dist/common/ws-rpc-schema.js +3 -3
- package/dist/common/ws-rpc-schema.js.map +1 -1
- package/package.json +72 -14
- package/src/cf-worker/do/durable-object.ts +23 -18
- package/src/cf-worker/do/layer.ts +35 -13
- package/src/cf-worker/do/pull.ts +43 -14
- package/src/cf-worker/do/push.ts +107 -46
- package/src/cf-worker/do/sqlite.ts +14 -4
- package/src/cf-worker/do/sync-storage.ts +151 -31
- package/src/cf-worker/do/transport/do-rpc-server.ts +22 -9
- package/src/cf-worker/do/transport/http-rpc-server.ts +33 -13
- package/src/cf-worker/do/transport/ws-rpc-server.ts +40 -12
- package/src/cf-worker/shared.ts +149 -25
- package/src/cf-worker/worker.ts +138 -108
- package/src/client/transport/do-rpc-client.ts +41 -17
- package/src/client/transport/http-rpc-client.ts +43 -17
- package/src/client/transport/ws-rpc-client.ts +42 -19
- package/src/common/constants.ts +18 -0
- package/src/common/do-rpc-schema.ts +5 -4
- package/src/common/http-rpc-schema.ts +5 -4
- package/src/common/mod.ts +4 -2
- package/src/common/sync-message-types.ts +3 -3
- package/src/common/ws-rpc-schema.ts +4 -3
|
@@ -1,9 +1,10 @@
|
|
|
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'
|
|
3
|
+
import type { LiveStoreEvent } from '@livestore/common/schema'
|
|
4
4
|
import { Chunk, Effect, Option, 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
|
|
|
9
10
|
export type SyncStorage = {
|
|
@@ -12,26 +13,30 @@ export type SyncStorage = {
|
|
|
12
13
|
{
|
|
13
14
|
total: number
|
|
14
15
|
stream: Stream.Stream<
|
|
15
|
-
{ eventEncoded: LiveStoreEvent.
|
|
16
|
-
|
|
16
|
+
{ eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> },
|
|
17
|
+
UnknownError
|
|
17
18
|
>
|
|
18
19
|
},
|
|
19
|
-
|
|
20
|
+
UnknownError
|
|
20
21
|
>
|
|
21
22
|
appendEvents: (
|
|
22
|
-
batch: ReadonlyArray<LiveStoreEvent.
|
|
23
|
+
batch: ReadonlyArray<LiveStoreEvent.Global.Encoded>,
|
|
23
24
|
createdAt: string,
|
|
24
|
-
) => Effect.Effect<void,
|
|
25
|
-
resetStore: Effect.Effect<void,
|
|
25
|
+
) => Effect.Effect<void, UnknownError>
|
|
26
|
+
resetStore: Effect.Effect<void, UnknownError>
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
export const makeStorage = (
|
|
29
|
+
export const makeStorage = (
|
|
30
|
+
ctx: CfTypes.DurableObjectState,
|
|
31
|
+
storeId: StoreId,
|
|
32
|
+
engine: { _tag: 'd1'; db: CfTypes.D1Database } | { _tag: 'do-sqlite' },
|
|
33
|
+
): SyncStorage => {
|
|
29
34
|
const dbName = `eventlog_${PERSISTENCE_FORMAT_VERSION}_${toValidTableName(storeId)}`
|
|
30
35
|
|
|
31
36
|
const execDb = <T>(cb: (db: CfTypes.D1Database) => Promise<CfTypes.D1Result<T>>) =>
|
|
32
37
|
Effect.tryPromise({
|
|
33
|
-
try: () => cb(
|
|
34
|
-
catch: (error) => new
|
|
38
|
+
try: () => cb(engine._tag === 'd1' ? engine.db : (undefined as never)),
|
|
39
|
+
catch: (error) => new UnknownError({ cause: error, payload: { dbName } }),
|
|
35
40
|
}).pipe(
|
|
36
41
|
Effect.map((_) => _.results),
|
|
37
42
|
Effect.withSpan('@livestore/sync-cf:durable-object:execDb'),
|
|
@@ -47,6 +52,7 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
|
|
|
47
52
|
const D1_MIN_PAGE_SIZE = 1
|
|
48
53
|
|
|
49
54
|
const decodeEventlogRows = Schema.decodeUnknownSync(Schema.Array(eventlogTable.rowSchema))
|
|
55
|
+
const jsonStringify = Schema.encodeSync(Schema.parseJson())
|
|
50
56
|
const textEncoder = new TextEncoder()
|
|
51
57
|
|
|
52
58
|
const decreaseLimit = (limit: number) => Math.max(D1_MIN_PAGE_SIZE, Math.floor(limit / 2))
|
|
@@ -66,17 +72,17 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
|
|
|
66
72
|
return limit
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
const
|
|
75
|
+
const getEventsD1 = (
|
|
70
76
|
cursor: number | undefined,
|
|
71
77
|
): Effect.Effect<
|
|
72
78
|
{
|
|
73
79
|
total: number
|
|
74
80
|
stream: Stream.Stream<
|
|
75
|
-
{ eventEncoded: LiveStoreEvent.
|
|
76
|
-
|
|
81
|
+
{ eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> },
|
|
82
|
+
UnknownError
|
|
77
83
|
>
|
|
78
84
|
},
|
|
79
|
-
|
|
85
|
+
UnknownError
|
|
80
86
|
> =>
|
|
81
87
|
Effect.gen(function* () {
|
|
82
88
|
const countStatement =
|
|
@@ -92,13 +98,13 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
|
|
|
92
98
|
const total = Number(countRows[0]?.total ?? 0)
|
|
93
99
|
|
|
94
100
|
type State = { cursor: number | undefined; limit: number }
|
|
95
|
-
type EmittedEvent = { eventEncoded: LiveStoreEvent.
|
|
101
|
+
type EmittedEvent = { eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> }
|
|
96
102
|
|
|
97
103
|
const initialState: State = { cursor, limit: D1_INITIAL_PAGE_SIZE }
|
|
98
104
|
|
|
99
105
|
const fetchPage = (
|
|
100
106
|
state: State,
|
|
101
|
-
): Effect.Effect<Option.Option<readonly [Chunk.Chunk<EmittedEvent>, State]>,
|
|
107
|
+
): Effect.Effect<Option.Option<readonly [Chunk.Chunk<EmittedEvent>, State]>, UnknownError> =>
|
|
102
108
|
Effect.gen(function* () {
|
|
103
109
|
const statement =
|
|
104
110
|
state.cursor === undefined
|
|
@@ -116,7 +122,7 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
|
|
|
116
122
|
return Option.none()
|
|
117
123
|
}
|
|
118
124
|
|
|
119
|
-
const encodedSize = textEncoder.encode(
|
|
125
|
+
const encodedSize = textEncoder.encode(jsonStringify(rawEvents)).byteLength
|
|
120
126
|
|
|
121
127
|
if (encodedSize > D1_TARGET_RESPONSE_BYTES && state.limit > D1_MIN_PAGE_SIZE) {
|
|
122
128
|
const nextLimit = decreaseLimit(state.limit)
|
|
@@ -143,11 +149,13 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
|
|
|
143
149
|
|
|
144
150
|
return { total, stream }
|
|
145
151
|
}).pipe(
|
|
146
|
-
|
|
147
|
-
Effect.withSpan('@livestore/sync-cf:durable-object:getEvents', {
|
|
152
|
+
UnknownError.mapToUnknownError,
|
|
153
|
+
Effect.withSpan('@livestore/sync-cf:durable-object:getEvents', {
|
|
154
|
+
attributes: { dbName, cursor, engine: engine._tag },
|
|
155
|
+
}),
|
|
148
156
|
)
|
|
149
157
|
|
|
150
|
-
const
|
|
158
|
+
const appendEventsD1: SyncStorage['appendEvents'] = (batch, createdAt) =>
|
|
151
159
|
Effect.gen(function* () {
|
|
152
160
|
// If there are no events, do nothing.
|
|
153
161
|
if (batch.length === 0) return
|
|
@@ -182,24 +190,136 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
|
|
|
182
190
|
)
|
|
183
191
|
}
|
|
184
192
|
}).pipe(
|
|
185
|
-
|
|
193
|
+
UnknownError.mapToUnknownError,
|
|
186
194
|
Effect.withSpan('@livestore/sync-cf:durable-object:appendEvents', {
|
|
187
|
-
attributes: { dbName, batchLength: batch.length },
|
|
195
|
+
attributes: { dbName, batchLength: batch.length, engine: engine._tag },
|
|
188
196
|
}),
|
|
189
197
|
)
|
|
190
198
|
|
|
191
199
|
const resetStore = Effect.promise(() => ctx.storage.deleteAll()).pipe(
|
|
192
|
-
|
|
200
|
+
UnknownError.mapToUnknownError,
|
|
193
201
|
Effect.withSpan('@livestore/sync-cf:durable-object:resetStore'),
|
|
194
202
|
)
|
|
195
203
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
204
|
+
// DO SQLite engine implementation
|
|
205
|
+
const getEventsDoSqlite = (
|
|
206
|
+
cursor: number | undefined,
|
|
207
|
+
): Effect.Effect<
|
|
208
|
+
{
|
|
209
|
+
total: number
|
|
210
|
+
stream: Stream.Stream<
|
|
211
|
+
{ eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> },
|
|
212
|
+
UnknownError
|
|
213
|
+
>
|
|
214
|
+
},
|
|
215
|
+
UnknownError
|
|
216
|
+
> =>
|
|
217
|
+
Effect.gen(function* () {
|
|
218
|
+
const selectCountSql =
|
|
219
|
+
cursor === undefined
|
|
220
|
+
? `SELECT COUNT(*) as total FROM "${dbName}"`
|
|
221
|
+
: `SELECT COUNT(*) as total FROM "${dbName}" WHERE seqNum > ?`
|
|
222
|
+
|
|
223
|
+
const total = yield* Effect.try({
|
|
224
|
+
try: () => {
|
|
225
|
+
const cursorIter =
|
|
226
|
+
cursor === undefined ? ctx.storage.sql.exec(selectCountSql) : ctx.storage.sql.exec(selectCountSql, cursor)
|
|
227
|
+
let computed = 0
|
|
228
|
+
for (const row of cursorIter) {
|
|
229
|
+
computed = Number((row as any).total ?? 0)
|
|
230
|
+
}
|
|
231
|
+
return computed
|
|
232
|
+
},
|
|
233
|
+
catch: (error) => new UnknownError({ cause: error, payload: { dbName, stage: 'count' } }),
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
type State = { cursor: number | undefined }
|
|
237
|
+
type EmittedEvent = { eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> }
|
|
238
|
+
|
|
239
|
+
const DO_PAGE_SIZE = 256
|
|
240
|
+
const initialState: State = { cursor }
|
|
241
|
+
|
|
242
|
+
const fetchPage = (
|
|
243
|
+
state: State,
|
|
244
|
+
): Effect.Effect<Option.Option<readonly [Chunk.Chunk<EmittedEvent>, State]>, UnknownError> =>
|
|
245
|
+
Effect.try({
|
|
246
|
+
try: () => {
|
|
247
|
+
const sql =
|
|
248
|
+
state.cursor === undefined
|
|
249
|
+
? `SELECT * FROM "${dbName}" ORDER BY seqNum ASC LIMIT ?`
|
|
250
|
+
: `SELECT * FROM "${dbName}" WHERE seqNum > ? ORDER BY seqNum ASC LIMIT ?`
|
|
251
|
+
|
|
252
|
+
const iter =
|
|
253
|
+
state.cursor === undefined
|
|
254
|
+
? ctx.storage.sql.exec(sql, DO_PAGE_SIZE)
|
|
255
|
+
: ctx.storage.sql.exec(sql, state.cursor, DO_PAGE_SIZE)
|
|
256
|
+
|
|
257
|
+
const rows: any[] = []
|
|
258
|
+
for (const row of iter) rows.push(row)
|
|
259
|
+
|
|
260
|
+
if (rows.length === 0) {
|
|
261
|
+
return Option.none()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const decodedRows = Chunk.fromIterable(decodeEventlogRows(rows))
|
|
265
|
+
const eventsChunk = Chunk.map(decodedRows, ({ createdAt, ...eventEncoded }) => ({
|
|
266
|
+
eventEncoded,
|
|
267
|
+
metadata: Option.some(SyncMetadata.make({ createdAt })),
|
|
268
|
+
}))
|
|
269
|
+
|
|
270
|
+
const lastSeqNum = Chunk.unsafeLast(decodedRows).seqNum
|
|
271
|
+
const nextState: State = { cursor: lastSeqNum }
|
|
272
|
+
|
|
273
|
+
return Option.some([eventsChunk, nextState] as const)
|
|
274
|
+
},
|
|
275
|
+
catch: (error) => new UnknownError({ cause: error, payload: { dbName, stage: 'select' } }),
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
const stream = Stream.unfoldChunkEffect(initialState, fetchPage)
|
|
279
|
+
|
|
280
|
+
return { total, stream }
|
|
281
|
+
}).pipe(
|
|
282
|
+
UnknownError.mapToUnknownError,
|
|
283
|
+
Effect.withSpan('@livestore/sync-cf:durable-object:getEvents', {
|
|
284
|
+
attributes: { dbName, cursor, engine: engine._tag },
|
|
285
|
+
}),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
const appendEventsDoSqlite: SyncStorage['appendEvents'] = (batch, createdAt) =>
|
|
289
|
+
Effect.try({
|
|
290
|
+
try: () => {
|
|
291
|
+
if (batch.length === 0) return
|
|
292
|
+
// Keep params per statement within conservative limits (align with D1 bound params ~100)
|
|
293
|
+
const CHUNK_SIZE = 14
|
|
294
|
+
for (let i = 0; i < batch.length; i += CHUNK_SIZE) {
|
|
295
|
+
const chunk = batch.slice(i, i + CHUNK_SIZE)
|
|
296
|
+
const placeholders = chunk.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')
|
|
297
|
+
const sql = `INSERT INTO "${dbName}" (seqNum, parentSeqNum, args, name, createdAt, clientId, sessionId) VALUES ${placeholders}`
|
|
298
|
+
const params = chunk.flatMap((event) => [
|
|
299
|
+
event.seqNum,
|
|
300
|
+
event.parentSeqNum,
|
|
301
|
+
event.args === undefined ? null : JSON.stringify(event.args),
|
|
302
|
+
event.name,
|
|
303
|
+
createdAt,
|
|
304
|
+
event.clientId,
|
|
305
|
+
event.sessionId,
|
|
306
|
+
])
|
|
307
|
+
ctx.storage.sql.exec(sql, ...params)
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
catch: (error) => new UnknownError({ cause: error, payload: { dbName, stage: 'insert' } }),
|
|
311
|
+
}).pipe(
|
|
312
|
+
Effect.withSpan('@livestore/sync-cf:durable-object:appendEvents', {
|
|
313
|
+
attributes: { dbName, batchLength: batch.length, engine: engine._tag },
|
|
314
|
+
}),
|
|
315
|
+
UnknownError.mapToUnknownError,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if (engine._tag === 'd1') {
|
|
319
|
+
return { dbName, getEvents: getEventsD1, appendEvents: appendEventsD1, resetStore }
|
|
202
320
|
}
|
|
321
|
+
|
|
322
|
+
return { dbName, getEvents: getEventsDoSqlite, appendEvents: appendEventsDoSqlite, resetStore }
|
|
203
323
|
}
|
|
204
324
|
|
|
205
325
|
const toValidTableName = (str: string) => str.replaceAll(/[^a-zA-Z0-9]/g, '_')
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { toDurableObjectHandler } from '@livestore/common-cf'
|
|
1
|
+
import { UnknownError } from '@livestore/common'
|
|
2
|
+
import { type CfTypes, toDurableObjectHandler } from '@livestore/common-cf'
|
|
3
3
|
import {
|
|
4
4
|
Effect,
|
|
5
5
|
Headers,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
RpcSerialization,
|
|
12
12
|
Stream,
|
|
13
13
|
} from '@livestore/utils/effect'
|
|
14
|
+
|
|
14
15
|
import { SyncDoRpc } from '../../../common/do-rpc-schema.ts'
|
|
15
16
|
import { SyncMessage } from '../../../common/mod.ts'
|
|
16
17
|
import { DoCtx, type DoCtxInput } from '../layer.ts'
|
|
@@ -22,7 +23,9 @@ export interface DoRpcHandlerOptions {
|
|
|
22
23
|
input: Omit<DoCtxInput, 'from'>
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
export const createDoRpcHandler = (
|
|
26
|
+
export const createDoRpcHandler = (
|
|
27
|
+
options: DoRpcHandlerOptions,
|
|
28
|
+
): Effect.Effect<Uint8Array<ArrayBuffer> | CfTypes.ReadableStream> =>
|
|
26
29
|
Effect.gen(this, function* () {
|
|
27
30
|
const { payload, input } = options
|
|
28
31
|
// const { rpcSubscriptions, backendId, doOptions, ctx, env } = yield* DoCtx
|
|
@@ -37,17 +40,18 @@ export const createDoRpcHandler = (options: DoRpcHandlerOptions) =>
|
|
|
37
40
|
const { rpcSubscriptions } = yield* DoCtx
|
|
38
41
|
|
|
39
42
|
// TODO rename `req.rpcContext` to something more appropriate
|
|
40
|
-
if (req.rpcContext) {
|
|
43
|
+
if (req.rpcContext !== undefined) {
|
|
41
44
|
rpcSubscriptions.set(req.storeId, {
|
|
42
45
|
storeId: req.storeId,
|
|
43
|
-
payload: req.payload,
|
|
44
46
|
subscribedAt: Date.now(),
|
|
45
47
|
requestId: Headers.get(headers, 'x-rpc-request-id').pipe(Option.getOrThrow),
|
|
46
48
|
callerContext: req.rpcContext.callerContext,
|
|
49
|
+
...(req.payload !== undefined ? { payload: req.payload } : {}),
|
|
47
50
|
})
|
|
48
51
|
}
|
|
49
52
|
|
|
50
|
-
|
|
53
|
+
// DO-RPC doesn't have HTTP headers context - headers are undefined
|
|
54
|
+
return makeEndingPullStream({ req, payload: req.payload, headers: undefined })
|
|
51
55
|
}).pipe(
|
|
52
56
|
Stream.unwrap,
|
|
53
57
|
Stream.map((res) => ({
|
|
@@ -55,18 +59,27 @@ export const createDoRpcHandler = (options: DoRpcHandlerOptions) =>
|
|
|
55
59
|
rpcRequestId: Headers.get(headers, 'x-rpc-request-id').pipe(Option.getOrThrow),
|
|
56
60
|
})),
|
|
57
61
|
Stream.provideLayer(DoCtx.Default({ ...input, from: { storeId: req.storeId } })),
|
|
58
|
-
Stream.mapError((cause) =>
|
|
62
|
+
Stream.mapError((cause) =>
|
|
63
|
+
cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
|
|
64
|
+
? cause
|
|
65
|
+
: new UnknownError({ cause }),
|
|
66
|
+
),
|
|
59
67
|
Stream.tapErrorCause(Effect.log),
|
|
60
68
|
),
|
|
61
69
|
'SyncDoRpc.Push': (req) =>
|
|
62
70
|
Effect.gen(this, function* () {
|
|
63
71
|
const { doOptions, ctx, env, storeId } = yield* DoCtx
|
|
64
|
-
|
|
72
|
+
// DO-RPC doesn't have HTTP headers context - headers are undefined
|
|
73
|
+
const push = makePush({ storeId, payload: req.payload, headers: undefined, options: doOptions, ctx, env })
|
|
65
74
|
|
|
66
75
|
return yield* push(req)
|
|
67
76
|
}).pipe(
|
|
68
77
|
Effect.provide(DoCtx.Default({ ...input, from: { storeId: req.storeId } })),
|
|
69
|
-
Effect.mapError((cause) =>
|
|
78
|
+
Effect.mapError((cause) =>
|
|
79
|
+
cause._tag === 'UnknownError' || cause._tag === 'ServerAheadError' || cause._tag === 'BackendIdMismatchError'
|
|
80
|
+
? cause
|
|
81
|
+
: new UnknownError({ cause }),
|
|
82
|
+
),
|
|
70
83
|
Effect.tapCauseLogPretty,
|
|
71
84
|
),
|
|
72
85
|
})
|
|
@@ -1,31 +1,50 @@
|
|
|
1
1
|
import type { CfTypes } from '@livestore/common-cf'
|
|
2
2
|
import { Effect, HttpApp, 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'
|
|
6
|
+
import { headersRecordToMap } from '../../shared.ts'
|
|
5
7
|
import { 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 httpApp = RpcServer.toHttpApp(SyncHttpRpc).pipe(Effect.provide(handlerLayer))
|
|
22
|
+
const webHandler = yield* httpApp.pipe(Effect.map(HttpApp.toWebHandler))
|
|
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
46
|
const { ctx, env, doOptions, storeId } = yield* DoCtx
|
|
28
|
-
const push = makePush({ payload: undefined, options: doOptions, storeId, ctx, env })
|
|
47
|
+
const push = makePush({ payload: undefined, headers, options: doOptions, storeId, ctx, env })
|
|
29
48
|
|
|
30
49
|
return yield* push(req)
|
|
31
50
|
}),
|
|
@@ -35,3 +54,4 @@ const createHttpRpcLayer =
|
|
|
35
54
|
Layer.provideMerge(RpcServer.layerProtocolHttp({ path: '/http-rpc' })),
|
|
36
55
|
Layer.provideMerge(RpcSerialization.layerJson),
|
|
37
56
|
)
|
|
57
|
+
}
|
|
@@ -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, RpcServer, Schema, Stream } from '@livestore/utils/effect'
|
|
4
|
+
|
|
3
5
|
import { SyncWsRpc } from '../../../common/ws-rpc-schema.ts'
|
|
6
|
+
import { headersRecordToMap, WebSocketAttachmentSchema } from '../../shared.ts'
|
|
4
7
|
import { DoCtx, type DoCtxInput } from '../layer.ts'
|
|
5
8
|
import { makeEndingPullStream } from '../pull.ts'
|
|
6
9
|
import { makePush } from '../push.ts'
|
|
7
10
|
|
|
8
11
|
export const makeRpcServer = ({ doSelf, doOptions }: Omit<DoCtxInput, 'from'>) => {
|
|
9
|
-
// TODO implement admin requests
|
|
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.provideLayer(DoCtx.Default({ 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
29
|
const { doOptions, storeId, ctx, env } = yield* 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
36
|
Effect.provide(DoCtx.Default({ doSelf, doOptions, from: { storeId: req.storeId } })),
|
|
28
|
-
Effect.mapError((cause) =>
|
|
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.decodeUnknownEither(WebSocketAttachmentSchema)(attachment)
|
|
54
|
+
if (decoded._tag === 'Left') {
|
|
55
|
+
yield* Effect.logError('Failed to decode WebSocket attachment for forwarded headers', { error: decoded.left })
|
|
56
|
+
ws.close(1011, 'invalid-attachment')
|
|
57
|
+
return yield* Effect.die('Invalid WebSocket attachment (headers decode failed)')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const headers = headersRecordToMap(decoded.right.headers)
|
|
61
|
+
return headers
|
|
62
|
+
})
|