@livestore/sync-cf 0.4.0-dev.9 → 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.
Files changed (104) 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 +13 -8
  6. package/dist/cf-worker/do/durable-object.js.map +1 -1
  7. package/dist/cf-worker/do/layer.d.ts +5 -5
  8. package/dist/cf-worker/do/layer.d.ts.map +1 -1
  9. package/dist/cf-worker/do/layer.js +32 -9
  10. package/dist/cf-worker/do/layer.js.map +1 -1
  11. package/dist/cf-worker/do/pull.d.ts +7 -2
  12. package/dist/cf-worker/do/pull.d.ts.map +1 -1
  13. package/dist/cf-worker/do/pull.js +16 -10
  14. package/dist/cf-worker/do/pull.js.map +1 -1
  15. package/dist/cf-worker/do/push.d.ts +5 -4
  16. package/dist/cf-worker/do/push.d.ts.map +1 -1
  17. package/dist/cf-worker/do/push.js +25 -17
  18. package/dist/cf-worker/do/push.js.map +1 -1
  19. package/dist/cf-worker/do/sqlite.d.ts +10 -1
  20. package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
  21. package/dist/cf-worker/do/sqlite.js +13 -4
  22. package/dist/cf-worker/do/sqlite.js.map +1 -1
  23. package/dist/cf-worker/do/sync-storage.d.ts +14 -9
  24. package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
  25. package/dist/cf-worker/do/sync-storage.js +92 -18
  26. package/dist/cf-worker/do/sync-storage.js.map +1 -1
  27. package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
  28. package/dist/cf-worker/do/transport/do-rpc-server.js +13 -7
  29. package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
  30. package/dist/cf-worker/do/transport/http-rpc-server.d.ts +4 -2
  31. package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
  32. package/dist/cf-worker/do/transport/http-rpc-server.js +24 -15
  33. package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
  34. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +2 -1
  35. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
  36. package/dist/cf-worker/do/transport/ws-rpc-server.js +30 -8
  37. package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
  38. package/dist/cf-worker/shared.d.ts +118 -31
  39. package/dist/cf-worker/shared.d.ts.map +1 -1
  40. package/dist/cf-worker/shared.js +40 -7
  41. package/dist/cf-worker/shared.js.map +1 -1
  42. package/dist/cf-worker/worker.d.ts +46 -38
  43. package/dist/cf-worker/worker.d.ts.map +1 -1
  44. package/dist/cf-worker/worker.js +51 -34
  45. package/dist/cf-worker/worker.js.map +1 -1
  46. package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
  47. package/dist/client/transport/do-rpc-client.js +27 -10
  48. package/dist/client/transport/do-rpc-client.js.map +1 -1
  49. package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
  50. package/dist/client/transport/http-rpc-client.js +29 -9
  51. package/dist/client/transport/http-rpc-client.js.map +1 -1
  52. package/dist/client/transport/ws-rpc-client.d.ts +2 -1
  53. package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
  54. package/dist/client/transport/ws-rpc-client.js +31 -17
  55. package/dist/client/transport/ws-rpc-client.js.map +1 -1
  56. package/dist/common/constants.d.ts +7 -0
  57. package/dist/common/constants.d.ts.map +1 -0
  58. package/dist/common/constants.js +17 -0
  59. package/dist/common/constants.js.map +1 -0
  60. package/dist/common/do-rpc-schema.d.ts +6 -6
  61. package/dist/common/do-rpc-schema.d.ts.map +1 -1
  62. package/dist/common/do-rpc-schema.js +4 -4
  63. package/dist/common/do-rpc-schema.js.map +1 -1
  64. package/dist/common/http-rpc-schema.d.ts +4 -4
  65. package/dist/common/http-rpc-schema.d.ts.map +1 -1
  66. package/dist/common/http-rpc-schema.js +4 -4
  67. package/dist/common/http-rpc-schema.js.map +1 -1
  68. package/dist/common/mod.d.ts +4 -1
  69. package/dist/common/mod.d.ts.map +1 -1
  70. package/dist/common/mod.js +4 -1
  71. package/dist/common/mod.js.map +1 -1
  72. package/dist/common/sync-message-types.d.ts +7 -7
  73. package/dist/common/sync-message-types.js +3 -3
  74. package/dist/common/sync-message-types.js.map +1 -1
  75. package/dist/common/ws-rpc-schema.d.ts +3 -3
  76. package/dist/common/ws-rpc-schema.d.ts.map +1 -1
  77. package/dist/common/ws-rpc-schema.js +3 -3
  78. package/dist/common/ws-rpc-schema.js.map +1 -1
  79. package/package.json +72 -14
  80. package/src/cf-worker/do/durable-object.ts +19 -10
  81. package/src/cf-worker/do/layer.ts +35 -13
  82. package/src/cf-worker/do/pull.ts +31 -14
  83. package/src/cf-worker/do/push.ts +49 -34
  84. package/src/cf-worker/do/sqlite.ts +14 -4
  85. package/src/cf-worker/do/sync-storage.ts +151 -31
  86. package/src/cf-worker/do/transport/do-rpc-server.ts +18 -7
  87. package/src/cf-worker/do/transport/http-rpc-server.ts +33 -13
  88. package/src/cf-worker/do/transport/ws-rpc-server.ts +40 -12
  89. package/src/cf-worker/shared.ts +136 -25
  90. package/src/cf-worker/worker.ts +107 -54
  91. package/src/client/transport/do-rpc-client.ts +41 -17
  92. package/src/client/transport/http-rpc-client.ts +43 -17
  93. package/src/client/transport/ws-rpc-client.ts +42 -19
  94. package/src/common/constants.ts +18 -0
  95. package/src/common/do-rpc-schema.ts +5 -4
  96. package/src/common/http-rpc-schema.ts +5 -4
  97. package/src/common/mod.ts +4 -2
  98. package/src/common/sync-message-types.ts +3 -3
  99. package/src/common/ws-rpc-schema.ts +4 -3
  100. package/dist/cf-worker/do/ws-chunking.d.ts +0 -22
  101. package/dist/cf-worker/do/ws-chunking.d.ts.map +0 -1
  102. package/dist/cf-worker/do/ws-chunking.js +0 -49
  103. package/dist/cf-worker/do/ws-chunking.js.map +0 -1
  104. package/src/cf-worker/do/ws-chunking.ts +0 -76
@@ -1,36 +1,39 @@
1
1
  import {
2
2
  BackendIdMismatchError,
3
- InvalidPushError,
4
3
  ServerAheadError,
5
4
  SyncBackend,
6
- UnexpectedError,
5
+ UnknownError,
7
6
  } from '@livestore/common'
8
7
  import { type CfTypes, emitStreamResponse } from '@livestore/common-cf'
8
+ import { splitChunkBySize } from '@livestore/common/sync'
9
9
  import { Chunk, Effect, Option, type RpcMessage, Schema } from '@livestore/utils/effect'
10
+
11
+ import { MAX_PUSH_EVENTS_PER_REQUEST, MAX_WS_MESSAGE_BYTES } from '../../common/constants.ts'
10
12
  import { SyncMessage } from '../../common/mod.ts'
11
13
  import {
12
14
  type Env,
13
- MAX_PULL_EVENTS_PER_MESSAGE,
14
- MAX_WS_MESSAGE_BYTES,
15
+ type ForwardedHeaders,
15
16
  type MakeDurableObjectClassOptions,
16
17
  type StoreId,
17
18
  WebSocketAttachmentSchema,
18
19
  } from '../shared.ts'
19
20
  import { DoCtx } from './layer.ts'
20
- import { splitChunkBySize } from './ws-chunking.ts'
21
21
 
22
22
  const encodePullResponse = Schema.encodeSync(SyncMessage.PullResponse)
23
+ const jsonStringify = Schema.encodeSync(Schema.parseJson())
23
24
  type PullBatchItem = SyncMessage.PullResponse['batch'][number]
24
25
 
25
26
  export const makePush =
26
27
  ({
27
28
  payload,
29
+ headers,
28
30
  options,
29
31
  storeId,
30
32
  ctx,
31
33
  env,
32
34
  }: {
33
35
  payload: Schema.JsonValue | undefined
36
+ headers: ForwardedHeaders | undefined
34
37
  options: MakeDurableObjectClassOptions | undefined
35
38
  storeId: StoreId
36
39
  ctx: CfTypes.DurableObjectState
@@ -45,9 +48,15 @@ export const makePush =
45
48
  return SyncMessage.PushAck.make({})
46
49
  }
47
50
 
48
- if (options?.onPush) {
49
- yield* Effect.tryAll(() => options.onPush!(pushRequest, { storeId, payload })).pipe(
50
- UnexpectedError.mapToUnexpectedError,
51
+ if (options?.onPush !== undefined) {
52
+ yield* Effect.tryAll(() =>
53
+ options.onPush!(pushRequest, {
54
+ storeId,
55
+ ...(payload !== undefined ? { payload } : {}),
56
+ ...(headers !== undefined ? { headers } : {}),
57
+ }),
58
+ ).pipe(
59
+ UnknownError.mapToUnknownError,
51
60
  )
52
61
  }
53
62
 
@@ -87,9 +96,9 @@ export const makePush =
87
96
  const connectedClients = ctx.getWebSockets()
88
97
 
89
98
  // Preparing chunks of responses to make sure we don't exceed the WS message size limit.
90
- const responses = Chunk.fromIterable(pushRequest.batch).pipe(
99
+ const responses = yield* Chunk.fromIterable(pushRequest.batch).pipe(
91
100
  splitChunkBySize({
92
- maxItems: MAX_PULL_EVENTS_PER_MESSAGE,
101
+ maxItems: MAX_PUSH_EVENTS_PER_REQUEST,
93
102
  maxBytes: MAX_WS_MESSAGE_BYTES,
94
103
  encode: (items) =>
95
104
  encodePullResponse(
@@ -105,23 +114,25 @@ export const makePush =
105
114
  }),
106
115
  ),
107
116
  }),
108
- Chunk.map((eventsChunk) => {
109
- const batchWithMetadata = Chunk.toReadonlyArray(eventsChunk).map((eventEncoded) => ({
110
- eventEncoded,
111
- metadata: Option.some(SyncMessage.SyncMetadata.make({ createdAt })),
112
- }))
113
-
114
- const response = SyncMessage.PullResponse.make({
115
- batch: batchWithMetadata,
116
- pageInfo: SyncBackend.pageInfoNoMore,
117
- backendId,
118
- })
119
-
120
- return {
121
- response,
122
- encoded: Schema.encodeSync(SyncMessage.PullResponse)(response),
123
- }
124
- }),
117
+ Effect.map(
118
+ Chunk.map((eventsChunk) => {
119
+ const batchWithMetadata = Chunk.toReadonlyArray(eventsChunk).map((eventEncoded) => ({
120
+ eventEncoded,
121
+ metadata: Option.some(SyncMessage.SyncMetadata.make({ createdAt })),
122
+ }))
123
+
124
+ const response = SyncMessage.PullResponse.make({
125
+ batch: batchWithMetadata,
126
+ pageInfo: SyncBackend.pageInfoNoMore,
127
+ backendId,
128
+ })
129
+
130
+ return {
131
+ response,
132
+ encoded: Schema.encodeSync(SyncMessage.PullResponse)(response),
133
+ }
134
+ }),
135
+ ),
125
136
  )
126
137
 
127
138
  // Dual broadcasting: WebSocket + RPC clients
@@ -130,13 +141,13 @@ export const makePush =
130
141
  if (connectedClients.length > 0) {
131
142
  for (const { response, encoded } of responses) {
132
143
  // Only calling once for now.
133
- if (options?.onPullRes) {
134
- yield* Effect.tryAll(() => options.onPullRes!(response)).pipe(UnexpectedError.mapToUnexpectedError)
144
+ if (options?.onPullRes !== undefined) {
145
+ yield* Effect.tryAll(() => options.onPullRes!(response)).pipe(UnknownError.mapToUnknownError)
135
146
  }
136
147
 
137
148
  // NOTE we're also sending the pullRes chunk to the pushing ws client as confirmation
138
149
  for (const conn of connectedClients) {
139
- const attachment = Schema.decodeSync(WebSocketAttachmentSchema)(conn.deserializeAttachment())
150
+ const attachment = yield* Schema.decode(WebSocketAttachmentSchema)(conn.deserializeAttachment())
140
151
 
141
152
  // We're doing something a bit "advanced" here as we're directly emitting Effect RPC-compatible
142
153
  // response messsages on the Effect RPC-managed websocket connection to the WS client.
@@ -147,7 +158,7 @@ export const makePush =
147
158
  requestId,
148
159
  values: [encoded],
149
160
  }
150
- conn.send(JSON.stringify(res))
161
+ conn.send(jsonStringify(res))
151
162
  }
152
163
  }
153
164
  }
@@ -184,12 +195,16 @@ export const makePush =
184
195
  }).pipe(
185
196
  Effect.tap(
186
197
  Effect.fn(function* (message) {
187
- if (options?.onPushRes) {
188
- yield* Effect.tryAll(() => options.onPushRes!(message)).pipe(UnexpectedError.mapToUnexpectedError)
198
+ if (options?.onPushRes !== undefined) {
199
+ yield* Effect.tryAll(() => options.onPushRes!(message)).pipe(UnknownError.mapToUnknownError)
189
200
  }
190
201
  }),
191
202
  ),
192
- Effect.mapError((cause) => InvalidPushError.make({ cause })),
203
+ Effect.mapError((cause) =>
204
+ cause._tag === 'BackendIdMismatchError' || cause._tag === 'ServerAheadError' || cause._tag === 'UnknownError'
205
+ ? cause
206
+ : new UnknownError({ cause }),
207
+ ),
193
208
  Effect.withSpan('sync-cf:do:push', { attributes: { storeId, batchSize: pushRequest.batch.length } }),
194
209
  )
195
210
 
@@ -1,13 +1,19 @@
1
1
  import { EventSequenceNumber, State } from '@livestore/common/schema'
2
2
  import { Schema } from '@livestore/utils/effect'
3
+
3
4
  import { PERSISTENCE_FORMAT_VERSION } from '../shared.ts'
4
5
 
6
+ /**
7
+ * Main event log table storing all LiveStore events.
8
+ *
9
+ * ⚠️ IMPORTANT: Any changes to this schema require bumping PERSISTENCE_FORMAT_VERSION in shared.ts
10
+ */
5
11
  export const eventlogTable = State.SQLite.table({
6
12
  // NOTE actual table name is determined at runtime to use proper storeId
7
13
  name: `eventlog_${PERSISTENCE_FORMAT_VERSION}_$storeId`,
8
14
  columns: {
9
- seqNum: State.SQLite.integer({ primaryKey: true, schema: EventSequenceNumber.GlobalEventSequenceNumber }),
10
- parentSeqNum: State.SQLite.integer({ schema: EventSequenceNumber.GlobalEventSequenceNumber }),
15
+ seqNum: State.SQLite.integer({ primaryKey: true, schema: EventSequenceNumber.Global.Schema }),
16
+ parentSeqNum: State.SQLite.integer({ schema: EventSequenceNumber.Global.Schema }),
11
17
  name: State.SQLite.text({}),
12
18
  args: State.SQLite.text({ schema: Schema.parseJson(Schema.Any), nullable: true }),
13
19
  /** ISO date format. Currently only used for debugging purposes. */
@@ -17,12 +23,16 @@ export const eventlogTable = State.SQLite.table({
17
23
  },
18
24
  })
19
25
 
20
- /** Will only ever have one row per durable object. */
26
+ /**
27
+ * Context metadata table - one row per Durable Object.
28
+ *
29
+ * ⚠️ IMPORTANT: Any changes to this schema require bumping PERSISTENCE_FORMAT_VERSION in shared.ts
30
+ */
21
31
  export const contextTable = State.SQLite.table({
22
32
  name: `context_${PERSISTENCE_FORMAT_VERSION}`,
23
33
  columns: {
24
34
  storeId: State.SQLite.text({ primaryKey: true }),
25
- currentHead: State.SQLite.integer({ schema: EventSequenceNumber.GlobalEventSequenceNumber }),
35
+ currentHead: State.SQLite.integer({ schema: EventSequenceNumber.Global.Schema }),
26
36
  backendId: State.SQLite.text({}),
27
37
  },
28
38
  })
@@ -1,9 +1,10 @@
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'
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 { 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
 
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.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> },
16
- UnexpectedError
16
+ { eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> },
17
+ UnknownError
17
18
  >
18
19
  },
19
- UnexpectedError
20
+ UnknownError
20
21
  >
21
22
  appendEvents: (
22
- batch: ReadonlyArray<LiveStoreEvent.AnyEncodedGlobal>,
23
+ batch: ReadonlyArray<LiveStoreEvent.Global.Encoded>,
23
24
  createdAt: string,
24
- ) => Effect.Effect<void, UnexpectedError>
25
- resetStore: Effect.Effect<void, UnexpectedError>
25
+ ) => Effect.Effect<void, UnknownError>
26
+ resetStore: Effect.Effect<void, UnknownError>
26
27
  }
27
28
 
28
- export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId: StoreId): SyncStorage => {
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(env.DB),
34
- catch: (error) => new UnexpectedError({ cause: error, payload: { dbName } }),
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 getEvents = (
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.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> },
76
- UnexpectedError
81
+ { eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> },
82
+ UnknownError
77
83
  >
78
84
  },
79
- UnexpectedError
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.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> }
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]>, UnexpectedError> =>
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(JSON.stringify(rawEvents)).byteLength
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
- UnexpectedError.mapToUnexpectedError,
147
- Effect.withSpan('@livestore/sync-cf:durable-object:getEvents', { attributes: { dbName, cursor } }),
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 appendEvents: SyncStorage['appendEvents'] = (batch, createdAt) =>
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
- UnexpectedError.mapToUnexpectedError,
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
- UnexpectedError.mapToUnexpectedError,
200
+ UnknownError.mapToUnknownError,
193
201
  Effect.withSpan('@livestore/sync-cf:durable-object:resetStore'),
194
202
  )
195
203
 
196
- return {
197
- dbName,
198
- // getHead,
199
- getEvents,
200
- appendEvents,
201
- resetStore,
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,4 +1,4 @@
1
- import { InvalidPullError, InvalidPushError } from '@livestore/common'
1
+ import { UnknownError } from '@livestore/common'
2
2
  import { type CfTypes, toDurableObjectHandler } from '@livestore/common-cf'
3
3
  import {
4
4
  Effect,
@@ -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'
@@ -39,17 +40,18 @@ export const createDoRpcHandler = (
39
40
  const { rpcSubscriptions } = yield* DoCtx
40
41
 
41
42
  // TODO rename `req.rpcContext` to something more appropriate
42
- if (req.rpcContext) {
43
+ if (req.rpcContext !== undefined) {
43
44
  rpcSubscriptions.set(req.storeId, {
44
45
  storeId: req.storeId,
45
- payload: req.payload,
46
46
  subscribedAt: Date.now(),
47
47
  requestId: Headers.get(headers, 'x-rpc-request-id').pipe(Option.getOrThrow),
48
48
  callerContext: req.rpcContext.callerContext,
49
+ ...(req.payload !== undefined ? { payload: req.payload } : {}),
49
50
  })
50
51
  }
51
52
 
52
- return makeEndingPullStream(req, req.payload)
53
+ // DO-RPC doesn't have HTTP headers context - headers are undefined
54
+ return makeEndingPullStream({ req, payload: req.payload, headers: undefined })
53
55
  }).pipe(
54
56
  Stream.unwrap,
55
57
  Stream.map((res) => ({
@@ -57,18 +59,27 @@ export const createDoRpcHandler = (
57
59
  rpcRequestId: Headers.get(headers, 'x-rpc-request-id').pipe(Option.getOrThrow),
58
60
  })),
59
61
  Stream.provideLayer(DoCtx.Default({ ...input, from: { storeId: req.storeId } })),
60
- Stream.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
62
+ Stream.mapError((cause) =>
63
+ cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
64
+ ? cause
65
+ : new UnknownError({ cause }),
66
+ ),
61
67
  Stream.tapErrorCause(Effect.log),
62
68
  ),
63
69
  'SyncDoRpc.Push': (req) =>
64
70
  Effect.gen(this, function* () {
65
71
  const { doOptions, ctx, env, storeId } = yield* DoCtx
66
- const push = makePush({ storeId, payload: req.payload, options: doOptions, ctx, env })
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 })
67
74
 
68
75
  return yield* push(req)
69
76
  }).pipe(
70
77
  Effect.provide(DoCtx.Default({ ...input, from: { storeId: req.storeId } })),
71
- Effect.mapError((cause) => (cause._tag === 'InvalidPushError' ? cause : InvalidPushError.make({ cause }))),
78
+ Effect.mapError((cause) =>
79
+ cause._tag === 'UnknownError' || cause._tag === 'ServerAheadError' || cause._tag === 'BackendIdMismatchError'
80
+ ? cause
81
+ : new UnknownError({ cause }),
82
+ ),
72
83
  Effect.tapCauseLogPretty,
73
84
  ),
74
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 = ({ 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 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
- 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
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
+ }