@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.
Files changed (113) hide show
  1. package/README.md +7 -8
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cf-worker/do/durable-object.d.ts +1 -1
  4. package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
  5. package/dist/cf-worker/do/durable-object.js +36 -18
  6. package/dist/cf-worker/do/durable-object.js.map +1 -1
  7. package/dist/cf-worker/do/layer.d.ts +24 -20
  8. package/dist/cf-worker/do/layer.d.ts.map +1 -1
  9. package/dist/cf-worker/do/layer.js +57 -24
  10. package/dist/cf-worker/do/layer.js.map +1 -1
  11. package/dist/cf-worker/do/pull.d.ts +8 -3
  12. package/dist/cf-worker/do/pull.d.ts.map +1 -1
  13. package/dist/cf-worker/do/pull.js +26 -21
  14. package/dist/cf-worker/do/pull.js.map +1 -1
  15. package/dist/cf-worker/do/push.d.ts +20 -6
  16. package/dist/cf-worker/do/push.d.ts.map +1 -1
  17. package/dist/cf-worker/do/push.js +84 -56
  18. package/dist/cf-worker/do/push.js.map +1 -1
  19. package/dist/cf-worker/do/push.test.d.ts +2 -0
  20. package/dist/cf-worker/do/push.test.d.ts.map +1 -0
  21. package/dist/cf-worker/do/push.test.js +30 -0
  22. package/dist/cf-worker/do/push.test.js.map +1 -0
  23. package/dist/cf-worker/do/sqlite.d.ts +49 -52
  24. package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
  25. package/dist/cf-worker/do/sqlite.js +14 -5
  26. package/dist/cf-worker/do/sqlite.js.map +1 -1
  27. package/dist/cf-worker/do/sync-storage.d.ts +15 -9
  28. package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
  29. package/dist/cf-worker/do/sync-storage.js +111 -25
  30. package/dist/cf-worker/do/sync-storage.js.map +1 -1
  31. package/dist/cf-worker/do/transport/do-rpc-server.d.ts +2 -2
  32. package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
  33. package/dist/cf-worker/do/transport/do-rpc-server.js +31 -23
  34. package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
  35. package/dist/cf-worker/do/transport/http-rpc-server.d.ts +5 -3
  36. package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
  37. package/dist/cf-worker/do/transport/http-rpc-server.js +28 -19
  38. package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
  39. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +3 -2
  40. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
  41. package/dist/cf-worker/do/transport/ws-rpc-server.js +32 -10
  42. package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
  43. package/dist/cf-worker/shared.d.ts +124 -34
  44. package/dist/cf-worker/shared.d.ts.map +1 -1
  45. package/dist/cf-worker/shared.js +52 -17
  46. package/dist/cf-worker/shared.js.map +1 -1
  47. package/dist/cf-worker/worker.d.ts +47 -39
  48. package/dist/cf-worker/worker.d.ts.map +1 -1
  49. package/dist/cf-worker/worker.js +54 -35
  50. package/dist/cf-worker/worker.js.map +1 -1
  51. package/dist/client/transport/do-rpc-client.d.ts +13 -4
  52. package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
  53. package/dist/client/transport/do-rpc-client.js +74 -28
  54. package/dist/client/transport/do-rpc-client.js.map +1 -1
  55. package/dist/client/transport/http-rpc-client.d.ts +3 -3
  56. package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
  57. package/dist/client/transport/http-rpc-client.js +51 -22
  58. package/dist/client/transport/http-rpc-client.js.map +1 -1
  59. package/dist/client/transport/ws-rpc-client.d.ts +4 -3
  60. package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
  61. package/dist/client/transport/ws-rpc-client.js +40 -22
  62. package/dist/client/transport/ws-rpc-client.js.map +1 -1
  63. package/dist/common/constants.d.ts +7 -0
  64. package/dist/common/constants.d.ts.map +1 -0
  65. package/dist/common/constants.js +17 -0
  66. package/dist/common/constants.js.map +1 -0
  67. package/dist/common/do-rpc-schema.d.ts +42 -43
  68. package/dist/common/do-rpc-schema.d.ts.map +1 -1
  69. package/dist/common/do-rpc-schema.js +5 -5
  70. package/dist/common/do-rpc-schema.js.map +1 -1
  71. package/dist/common/http-rpc-schema.d.ts +37 -38
  72. package/dist/common/http-rpc-schema.d.ts.map +1 -1
  73. package/dist/common/http-rpc-schema.js +7 -7
  74. package/dist/common/http-rpc-schema.js.map +1 -1
  75. package/dist/common/mod.d.ts +5 -3
  76. package/dist/common/mod.d.ts.map +1 -1
  77. package/dist/common/mod.js +4 -2
  78. package/dist/common/mod.js.map +1 -1
  79. package/dist/common/sync-message-types.d.ts +113 -130
  80. package/dist/common/sync-message-types.d.ts.map +1 -1
  81. package/dist/common/sync-message-types.js +29 -17
  82. package/dist/common/sync-message-types.js.map +1 -1
  83. package/dist/common/ws-rpc-schema.d.ts +34 -35
  84. package/dist/common/ws-rpc-schema.d.ts.map +1 -1
  85. package/dist/common/ws-rpc-schema.js +5 -5
  86. package/dist/common/ws-rpc-schema.js.map +1 -1
  87. package/package.json +57 -15
  88. package/src/cf-worker/do/durable-object.ts +59 -41
  89. package/src/cf-worker/do/layer.ts +153 -88
  90. package/src/cf-worker/do/pull.ts +48 -31
  91. package/src/cf-worker/do/push.test.ts +40 -0
  92. package/src/cf-worker/do/push.ts +163 -125
  93. package/src/cf-worker/do/sqlite.ts +15 -5
  94. package/src/cf-worker/do/sync-storage.ts +171 -38
  95. package/src/cf-worker/do/transport/do-rpc-server.ts +40 -40
  96. package/src/cf-worker/do/transport/http-rpc-server.ts +37 -20
  97. package/src/cf-worker/do/transport/ws-rpc-server.ts +44 -16
  98. package/src/cf-worker/shared.ts +152 -36
  99. package/src/cf-worker/worker.ts +110 -58
  100. package/src/client/transport/do-rpc-client.ts +106 -40
  101. package/src/client/transport/http-rpc-client.ts +74 -38
  102. package/src/client/transport/ws-rpc-client.ts +55 -29
  103. package/src/common/constants.ts +18 -0
  104. package/src/common/do-rpc-schema.ts +6 -5
  105. package/src/common/http-rpc-schema.ts +8 -7
  106. package/src/common/mod.ts +4 -3
  107. package/src/common/sync-message-types.ts +19 -19
  108. package/src/common/ws-rpc-schema.ts +6 -5
  109. package/dist/cf-worker/do/ws-chunking.d.ts +0 -22
  110. package/dist/cf-worker/do/ws-chunking.d.ts.map +0 -1
  111. package/dist/cf-worker/do/ws-chunking.js +0 -49
  112. package/dist/cf-worker/do/ws-chunking.js.map +0 -1
  113. package/src/cf-worker/do/ws-chunking.ts +0 -76
@@ -1,37 +1,40 @@
1
- import { UnexpectedError } from '@livestore/common'
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 { Chunk, Effect, Option, Schema, Stream } from '@livestore/utils/effect'
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 { type Env, PERSISTENCE_FORMAT_VERSION, type StoreId } from '../shared.ts'
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.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> },
16
- UnexpectedError
18
+ { eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> },
19
+ UnknownError
17
20
  >
18
21
  },
19
- UnexpectedError
22
+ UnknownError
20
23
  >
21
24
  appendEvents: (
22
- batch: ReadonlyArray<LiveStoreEvent.AnyEncodedGlobal>,
25
+ batch: ReadonlyArray<LiveStoreEvent.Global.Encoded>,
23
26
  createdAt: string,
24
- ) => Effect.Effect<void, UnexpectedError>
25
- resetStore: Effect.Effect<void, UnexpectedError>
27
+ ) => Effect.Effect<void, UnknownError>
28
+ resetStore: Effect.Effect<void, UnknownError>
26
29
  }
27
30
 
28
- export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId: StoreId): SyncStorage => {
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(env.DB),
34
- catch: (error) => new UnexpectedError({ cause: error, payload: { dbName } }),
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 getEvents = (
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.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> },
76
- UnexpectedError
79
+ { eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> },
80
+ UnknownError
77
81
  >
78
82
  },
79
- UnexpectedError
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.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> }
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<Option.Option<readonly [Chunk.Chunk<EmittedEvent>, State]>, UnexpectedError> =>
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
- return Option.none()
120
+ const done: readonly [ReadonlyArray<EmittedEvent>, Option.Option<State>] = [[], Option.none()]
121
+ return done
117
122
  }
118
123
 
119
- const encodedSize = textEncoder.encode(JSON.stringify(rawEvents)).byteLength
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 = Chunk.fromIterable(decodeEventlogRows(rawEvents))
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 eventsChunk = Chunk.map(decodedRows, ({ createdAt, ...eventEncoded }) => ({
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 = Chunk.unsafeLast(decodedRows).seqNum
146
+ const lastSeqNum = EffectArray.lastNonEmpty(decodedRows).seqNum
137
147
  const nextState: State = { cursor: lastSeqNum, limit: computeNextLimit(state.limit, encodedSize) }
138
148
 
139
- return Option.some([eventsChunk, nextState] as const)
149
+ const page: readonly [ReadonlyArray<EmittedEvent>, Option.Option<State>] = [events, Option.some(nextState)]
150
+ return page
140
151
  })
141
152
 
142
- const stream = Stream.unfoldChunkEffect(initialState, fetchPage)
153
+ const stream = Stream.paginate(initialState, fetchPage)
143
154
 
144
155
  return { total, stream }
145
156
  }).pipe(
146
- UnexpectedError.mapToUnexpectedError,
147
- Effect.withSpan('@livestore/sync-cf:durable-object:getEvents', { attributes: { dbName, cursor } }),
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 appendEvents: SyncStorage['appendEvents'] = (batch, createdAt) =>
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
- UnexpectedError.mapToUnexpectedError,
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
- UnexpectedError.mapToUnexpectedError,
205
+ UnknownError.mapToUnknownError,
193
206
  Effect.withSpan('@livestore/sync-cf:durable-object:resetStore'),
194
207
  )
195
208
 
196
- return {
197
- dbName,
198
- // getHead,
199
- getEvents,
200
- appendEvents,
201
- resetStore,
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 { InvalidPullError, InvalidPushError } from '@livestore/common'
1
+ import { UnknownError } from '@livestore/common'
2
2
  import { type CfTypes, toDurableObjectHandler } from '@livestore/common-cf'
3
- import {
4
- Effect,
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 { SyncMessage } from '../../../common/mod.ts'
16
- import { DoCtx, type DoCtxInput } from '../layer.ts'
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': (_req) => {
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 { rpcSubscriptions } = yield* DoCtx
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
- rpcSubscriptions.set(req.storeId, {
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
- return makeEndingPullStream(req, req.payload)
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.provideLayer(DoCtx.Default({ ...input, from: { storeId: req.storeId } })),
60
- Stream.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
61
- Stream.tapErrorCause(Effect.log),
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
- const push = makePush({ storeId, payload: req.payload, options: doOptions, ctx, env })
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.Default({ ...input, from: { storeId: req.storeId } })),
71
- Effect.mapError((cause) => (cause._tag === 'InvalidPushError' ? cause : InvalidPushError.make({ 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: Layer.mergeAll(RpcLive, RpcSerialization.layerJson, HttpServer.layerContext).pipe(
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, HttpApp, Layer, RpcSerialization, RpcServer } from '@livestore/utils/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 { DoCtx } from '../layer.ts'
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 = ({ request }: { request: CfTypes.Request }) =>
10
- Effect.gen(function* () {
11
- const handlerLayer = createHttpRpcLayer
12
- const httpApp = RpcServer.toHttpApp(SyncHttpRpc).pipe(Effect.provide(handlerLayer))
13
- const webHandler = yield* httpApp.pipe(Effect.map(HttpApp.toWebHandler))
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
- return yield* Effect.promise(
16
- () => webHandler(request as TODO as Request) as TODO as Promise<CfTypes.Response>,
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: undefined, options: doOptions, storeId, ctx, env })
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
- Layer.provideMerge(RpcServer.layerProtocolHttp({ path: '/http-rpc' })),
36
- Layer.provideMerge(RpcSerialization.layerJson),
37
- )
53
+ }).pipe(Layer.provideMerge(RpcSerialization.layerJson))
54
+ }
@@ -1,34 +1,62 @@
1
- import { InvalidPullError, InvalidPushError } from '@livestore/common'
2
- import { Effect, identity, Layer, RpcServer, Stream } from '@livestore/utils/effect'
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 { DoCtx, type DoCtxInput } from '../layer.ts'
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
- makeEndingPullStream(req, req.payload).pipe(
13
- // Needed to keep the stream alive on the client side for phase 2 (i.e. not send the `Exit` stream RPC message)
14
- req.live ? Stream.concat(Stream.never) : identity,
15
- Stream.provideLayer(DoCtx.Default({ doSelf, doOptions, from: { storeId: req.storeId } })),
16
- Stream.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
17
- // Stream.tapErrorCause(Effect.log),
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.Default({ doSelf, doOptions, from: { storeId: req.storeId } })),
28
- Effect.mapError((cause) => (cause._tag === 'InvalidPushError' ? cause : InvalidPushError.make({ 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
+ })