@livestore/sync-cf 0.4.0 → 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 (101) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
  3. package/dist/cf-worker/do/durable-object.js +24 -11
  4. package/dist/cf-worker/do/durable-object.js.map +1 -1
  5. package/dist/cf-worker/do/layer.d.ts +22 -18
  6. package/dist/cf-worker/do/layer.d.ts.map +1 -1
  7. package/dist/cf-worker/do/layer.js +43 -33
  8. package/dist/cf-worker/do/layer.js.map +1 -1
  9. package/dist/cf-worker/do/pull.d.ts +3 -3
  10. package/dist/cf-worker/do/pull.d.ts.map +1 -1
  11. package/dist/cf-worker/do/pull.js +17 -18
  12. package/dist/cf-worker/do/pull.js.map +1 -1
  13. package/dist/cf-worker/do/push.d.ts +16 -3
  14. package/dist/cf-worker/do/push.d.ts.map +1 -1
  15. package/dist/cf-worker/do/push.js +69 -49
  16. package/dist/cf-worker/do/push.js.map +1 -1
  17. package/dist/cf-worker/do/push.test.d.ts +2 -0
  18. package/dist/cf-worker/do/push.test.d.ts.map +1 -0
  19. package/dist/cf-worker/do/push.test.js +30 -0
  20. package/dist/cf-worker/do/push.test.js.map +1 -0
  21. package/dist/cf-worker/do/sqlite.d.ts +34 -46
  22. package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
  23. package/dist/cf-worker/do/sqlite.js +1 -1
  24. package/dist/cf-worker/do/sqlite.js.map +1 -1
  25. package/dist/cf-worker/do/sync-storage.d.ts +7 -6
  26. package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
  27. package/dist/cf-worker/do/sync-storage.js +26 -14
  28. package/dist/cf-worker/do/sync-storage.js.map +1 -1
  29. package/dist/cf-worker/do/transport/do-rpc-server.d.ts +2 -2
  30. package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
  31. package/dist/cf-worker/do/transport/do-rpc-server.js +21 -19
  32. package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
  33. package/dist/cf-worker/do/transport/http-rpc-server.d.ts +2 -2
  34. package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
  35. package/dist/cf-worker/do/transport/http-rpc-server.js +7 -7
  36. package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
  37. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +2 -2
  38. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
  39. package/dist/cf-worker/do/transport/ws-rpc-server.js +9 -9
  40. package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
  41. package/dist/cf-worker/shared.d.ts +22 -19
  42. package/dist/cf-worker/shared.d.ts.map +1 -1
  43. package/dist/cf-worker/shared.js +13 -11
  44. package/dist/cf-worker/shared.js.map +1 -1
  45. package/dist/cf-worker/worker.d.ts +6 -6
  46. package/dist/cf-worker/worker.d.ts.map +1 -1
  47. package/dist/cf-worker/worker.js +16 -14
  48. package/dist/cf-worker/worker.js.map +1 -1
  49. package/dist/client/transport/do-rpc-client.d.ts +13 -4
  50. package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
  51. package/dist/client/transport/do-rpc-client.js +55 -26
  52. package/dist/client/transport/do-rpc-client.js.map +1 -1
  53. package/dist/client/transport/http-rpc-client.d.ts +3 -3
  54. package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
  55. package/dist/client/transport/http-rpc-client.js +30 -21
  56. package/dist/client/transport/http-rpc-client.js.map +1 -1
  57. package/dist/client/transport/ws-rpc-client.d.ts +3 -3
  58. package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
  59. package/dist/client/transport/ws-rpc-client.js +20 -16
  60. package/dist/client/transport/ws-rpc-client.js.map +1 -1
  61. package/dist/common/do-rpc-schema.d.ts +38 -39
  62. package/dist/common/do-rpc-schema.d.ts.map +1 -1
  63. package/dist/common/do-rpc-schema.js +3 -3
  64. package/dist/common/do-rpc-schema.js.map +1 -1
  65. package/dist/common/http-rpc-schema.d.ts +36 -37
  66. package/dist/common/http-rpc-schema.d.ts.map +1 -1
  67. package/dist/common/http-rpc-schema.js +5 -5
  68. package/dist/common/http-rpc-schema.js.map +1 -1
  69. package/dist/common/mod.d.ts +4 -5
  70. package/dist/common/mod.d.ts.map +1 -1
  71. package/dist/common/mod.js +3 -4
  72. package/dist/common/mod.js.map +1 -1
  73. package/dist/common/sync-message-types.d.ts +116 -133
  74. package/dist/common/sync-message-types.d.ts.map +1 -1
  75. package/dist/common/sync-message-types.js +26 -14
  76. package/dist/common/sync-message-types.js.map +1 -1
  77. package/dist/common/ws-rpc-schema.d.ts +33 -34
  78. package/dist/common/ws-rpc-schema.d.ts.map +1 -1
  79. package/dist/common/ws-rpc-schema.js +4 -4
  80. package/dist/common/ws-rpc-schema.js.map +1 -1
  81. package/package.json +21 -37
  82. package/src/cf-worker/do/durable-object.ts +45 -36
  83. package/src/cf-worker/do/layer.ts +142 -99
  84. package/src/cf-worker/do/pull.ts +28 -28
  85. package/src/cf-worker/do/push.test.ts +40 -0
  86. package/src/cf-worker/do/push.ts +138 -115
  87. package/src/cf-worker/do/sqlite.ts +1 -1
  88. package/src/cf-worker/do/sync-storage.ts +34 -21
  89. package/src/cf-worker/do/transport/do-rpc-server.ts +23 -34
  90. package/src/cf-worker/do/transport/http-rpc-server.ts +7 -10
  91. package/src/cf-worker/do/transport/ws-rpc-server.ts +10 -10
  92. package/src/cf-worker/shared.ts +18 -13
  93. package/src/cf-worker/worker.ts +21 -22
  94. package/src/client/transport/do-rpc-client.ts +82 -40
  95. package/src/client/transport/http-rpc-client.ts +47 -37
  96. package/src/client/transport/ws-rpc-client.ts +29 -26
  97. package/src/common/do-rpc-schema.ts +3 -3
  98. package/src/common/http-rpc-schema.ts +5 -5
  99. package/src/common/mod.ts +3 -4
  100. package/src/common/sync-message-types.ts +16 -16
  101. package/src/common/ws-rpc-schema.ts +4 -4
@@ -0,0 +1,40 @@
1
+ import { Vitest } from '@livestore/utils-dev/node-vitest'
2
+ import { Deferred, Duration, Effect, Fiber, Semaphore } from '@livestore/utils/effect'
3
+
4
+ import { runSerializedPushAdmission } from './push.ts'
5
+
6
+ Vitest.describe('sync-cf push admission', () => {
7
+ Vitest.live('publishes an admitted push before honoring interruption', () =>
8
+ Effect.gen(function* () {
9
+ const semaphore = yield* Semaphore.make(1)
10
+ const persisted = yield* Deferred.make<void>()
11
+ const allowBroadcast = yield* Deferred.make<void>()
12
+ const broadcasted = yield* Deferred.make<void>()
13
+
14
+ const admissionFiber = yield* runSerializedPushAdmission(
15
+ semaphore,
16
+ Effect.gen(function* () {
17
+ yield* Deferred.succeed(persisted, undefined)
18
+ yield* Deferred.await(allowBroadcast)
19
+ yield* Deferred.succeed(broadcasted, undefined)
20
+ }),
21
+ ).pipe(Effect.forkChild)
22
+
23
+ // Reproduce cancellation after persistence but before the pull broadcast handoff.
24
+ yield* Deferred.await(persisted)
25
+ const interruptStarted = yield* Deferred.make<void>()
26
+ const interruptFiber = yield* Effect.gen(function* () {
27
+ yield* Deferred.succeed(interruptStarted, undefined)
28
+ yield* Fiber.interrupt(admissionFiber)
29
+ }).pipe(Effect.forkChild)
30
+ yield* Deferred.await(interruptStarted)
31
+ yield* Effect.yieldNow
32
+
33
+ yield* Deferred.succeed(allowBroadcast, undefined)
34
+ yield* Deferred.await(broadcasted).pipe(Effect.timeout(Duration.seconds(1)))
35
+ yield* Fiber.join(interruptFiber)
36
+
37
+ Vitest.expect(yield* Deferred.isDone(broadcasted)).toBe(true)
38
+ }),
39
+ )
40
+ })
@@ -1,12 +1,7 @@
1
- import {
2
- BackendIdMismatchError,
3
- ServerAheadError,
4
- SyncBackend,
5
- UnknownError,
6
- } from '@livestore/common'
1
+ import { BackendIdMismatchError, ServerAheadError, SyncBackend, UnknownError } from '@livestore/common'
7
2
  import { type CfTypes, emitStreamResponse } from '@livestore/common-cf'
8
- import { splitChunkBySize } from '@livestore/common/sync'
9
- import { Chunk, Effect, Option, type RpcMessage, Schema } from '@livestore/utils/effect'
3
+ import { splitArrayBySize } from '@livestore/common/sync'
4
+ import { Effect, Option, ReadonlyArray as EffectArray, type RpcMessage, Schema } from '@livestore/utils/effect'
10
5
 
11
6
  import { MAX_PUSH_EVENTS_PER_REQUEST, MAX_WS_MESSAGE_BYTES } from '../../common/constants.ts'
12
7
  import { SyncMessage } from '../../common/mod.ts'
@@ -14,14 +9,18 @@ import {
14
9
  type Env,
15
10
  type ForwardedHeaders,
16
11
  type MakeDurableObjectClassOptions,
12
+ rpcSubscriptionKeyPrefix,
13
+ type RpcSubscription,
17
14
  type StoreId,
18
15
  WebSocketAttachmentSchema,
19
16
  } from '../shared.ts'
20
- import { DoCtx } from './layer.ts'
17
+ import * as DoCtx from './layer.ts'
21
18
 
22
- const encodePullResponse = Schema.encodeSync(SyncMessage.PullResponse)
23
- const jsonStringify = Schema.encodeSync(Schema.parseJson())
19
+ const pullResponseJsonSchema = Schema.toCodecJson(SyncMessage.PullResponse)
20
+ const encodePullResponse = Schema.encodeSync(pullResponseJsonSchema)
21
+ const jsonStringify = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))
24
22
  type PullBatchItem = SyncMessage.PullResponse['batch'][number]
23
+ type PushBatchItem = SyncMessage.PushRequest['batch'][number]
25
24
 
26
25
  export const makePush =
27
26
  ({
@@ -32,7 +31,7 @@ export const makePush =
32
31
  ctx,
33
32
  env,
34
33
  }: {
35
- payload: Schema.JsonValue | undefined
34
+ payload: Schema.Json | undefined
36
35
  headers: ForwardedHeaders | undefined
37
36
  options: MakeDurableObjectClassOptions | undefined
38
37
  storeId: StoreId
@@ -42,65 +41,64 @@ export const makePush =
42
41
  (pushRequest: Omit<SyncMessage.PushRequest, '_tag'>) =>
43
42
  Effect.gen(function* () {
44
43
  // yield* Effect.log(`Pushing ${decodedMessage.batch.length} events`, decodedMessage.batch)
45
- const { backendId, storage, currentHeadRef, updateCurrentHead, rpcSubscriptions } = yield* DoCtx
44
+ const { backendId, storage, currentHeadRef, updateCurrentHead, pushSemaphore } = yield* DoCtx.DoCtx
46
45
 
47
46
  if (pushRequest.batch.length === 0) {
48
47
  return SyncMessage.PushAck.make({})
49
48
  }
50
49
 
51
50
  if (options?.onPush !== undefined) {
52
- yield* Effect.tryAll(() =>
51
+ yield* Effect.trySyncOrPromiseOrEffect(() =>
53
52
  options.onPush!(pushRequest, {
54
53
  storeId,
55
54
  ...(payload !== undefined ? { payload } : {}),
56
55
  ...(headers !== undefined ? { headers } : {}),
57
56
  }),
58
- ).pipe(
59
- UnknownError.mapToUnknownError,
60
- )
57
+ ).pipe(UnknownError.mapToUnknownError)
61
58
  }
62
59
 
63
60
  if (pushRequest.backendId._tag === 'Some' && pushRequest.backendId.value !== backendId) {
64
61
  return yield* new BackendIdMismatchError({ expected: backendId, received: pushRequest.backendId.value })
65
62
  }
66
63
 
67
- // This part of the code needs to run sequentially to avoid race conditions
68
- const { createdAt } = yield* Effect.gen(function* () {
69
- const currentHead = currentHeadRef.current
70
- // TODO handle clientId unique conflict
71
- // Validate the batch
72
- const firstEventParent = pushRequest.batch[0]!.parentSeqNum
73
- if (firstEventParent !== currentHead) {
74
- // yield* Effect.logDebug('ServerAheadError: backend head mismatch', {
75
- // expectedHead: currentHead,
76
- // providedHead: firstEventParent,
77
- // batchSize: pushRequest.batch.length,
78
- // backendId,
79
- // })
64
+ // A push is one ordered admit persist publish operation. `blockConcurrencyWhile` below protects
65
+ // the Durable Object state transition, while this semaphore keeps later pushes from overtaking this
66
+ // push during broadcasting without holding Cloudflare's concurrency gate across network I/O.
67
+ yield* runSerializedPushAdmission(
68
+ pushSemaphore,
69
+ Effect.gen(function* () {
70
+ // Keep the head check, durable append, and in-memory head update atomic with respect to other
71
+ // Durable Object requests. Broadcasting intentionally happens after this gate is released.
72
+ const { createdAt } = yield* Effect.gen(function* () {
73
+ const currentHead = currentHeadRef.current
74
+ // TODO handle clientId unique conflict
75
+ // Validate the batch
76
+ const firstEventParent = pushRequest.batch[0]!.parentSeqNum
77
+ if (firstEventParent !== currentHead) {
78
+ return yield* new ServerAheadError({ minimumExpectedNum: currentHead, providedNum: firstEventParent })
79
+ }
80
80
 
81
- return yield* new ServerAheadError({ minimumExpectedNum: currentHead, providedNum: firstEventParent })
82
- }
81
+ const createdAt = new Date().toISOString()
83
82
 
84
- const createdAt = new Date().toISOString()
83
+ // TODO possibly model this as a queue in order to speed up subsequent pushes
84
+ yield* storage.appendEvents(pushRequest.batch, createdAt)
85
85
 
86
- // TODO possibly model this as a queue in order to speed up subsequent pushes
87
- yield* storage.appendEvents(pushRequest.batch, createdAt)
86
+ updateCurrentHead(pushRequest.batch.at(-1)!.seqNum)
88
87
 
89
- updateCurrentHead(pushRequest.batch.at(-1)!.seqNum)
88
+ return { createdAt }
89
+ }).pipe(blockConcurrencyWhile(ctx))
90
90
 
91
- return { createdAt }
92
- }).pipe(blockConcurrencyWhile(ctx))
91
+ const connectedClients = ctx.getWebSockets()
93
92
 
94
- // Run in background but already return the push ack to the client
95
- yield* Effect.gen(function* () {
96
- const connectedClients = ctx.getWebSockets()
93
+ // Preparing chunks of responses to make sure we don't exceed the WS message size limit.
94
+ if (EffectArray.isReadonlyArrayNonEmpty(pushRequest.batch) === false) {
95
+ return
96
+ }
97
97
 
98
- // Preparing chunks of responses to make sure we don't exceed the WS message size limit.
99
- const responses = yield* Chunk.fromIterable(pushRequest.batch).pipe(
100
- splitChunkBySize({
98
+ const responses = yield* splitArrayBySize({
101
99
  maxItems: MAX_PUSH_EVENTS_PER_REQUEST,
102
100
  maxBytes: MAX_WS_MESSAGE_BYTES,
103
- encode: (items) =>
101
+ encode: (items: ReadonlyArray<PushBatchItem>) =>
104
102
  encodePullResponse(
105
103
  SyncMessage.PullResponse.make({
106
104
  batch: items.map(
@@ -113,90 +111,94 @@ export const makePush =
113
111
  backendId,
114
112
  }),
115
113
  ),
116
- }),
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),
114
+ })(pushRequest.batch).pipe(
115
+ Effect.map((eventBatch) =>
116
+ eventBatch.map((events) => {
117
+ const batchWithMetadata = events.map((eventEncoded) => ({
118
+ eventEncoded,
119
+ metadata: Option.some(SyncMessage.SyncMetadata.make({ createdAt })),
120
+ }))
121
+
122
+ const response = SyncMessage.PullResponse.make({
123
+ batch: batchWithMetadata,
124
+ pageInfo: SyncBackend.pageInfoNoMore,
125
+ backendId,
126
+ })
127
+
128
+ return {
129
+ response,
130
+ encoded: encodePullResponse(response),
131
+ }
132
+ }),
133
+ ),
134
+ )
135
+
136
+ // Dual broadcasting: WebSocket + RPC clients
137
+
138
+ // Broadcast to WebSocket clients
139
+ if (connectedClients.length > 0) {
140
+ for (const { response, encoded } of responses) {
141
+ // Only calling once for now.
142
+ if (options?.onPullRes !== undefined) {
143
+ yield* Effect.trySyncOrPromiseOrEffect(() => options.onPullRes!(response)).pipe(
144
+ UnknownError.mapToUnknownError,
145
+ )
133
146
  }
134
- }),
135
- ),
136
- )
137
-
138
- // Dual broadcasting: WebSocket + RPC clients
139
-
140
- // Broadcast to WebSocket clients
141
- if (connectedClients.length > 0) {
142
- for (const { response, encoded } of responses) {
143
- // Only calling once for now.
144
- if (options?.onPullRes !== undefined) {
145
- yield* Effect.tryAll(() => options.onPullRes!(response)).pipe(UnknownError.mapToUnknownError)
146
- }
147
147
 
148
- // NOTE we're also sending the pullRes chunk to the pushing ws client as confirmation
149
- for (const conn of connectedClients) {
150
- const attachment = yield* Schema.decode(WebSocketAttachmentSchema)(conn.deserializeAttachment())
151
-
152
- // We're doing something a bit "advanced" here as we're directly emitting Effect RPC-compatible
153
- // response messsages on the Effect RPC-managed websocket connection to the WS client.
154
- // For this we need to get the RPC `requestId` from the WebSocket attachment.
155
- for (const requestId of attachment.pullRequestIds) {
156
- const res: RpcMessage.ResponseChunkEncoded = {
157
- _tag: 'Chunk',
158
- requestId,
159
- values: [encoded],
148
+ // NOTE we're also sending the pullRes chunk to the pushing ws client as confirmation
149
+ for (const conn of connectedClients) {
150
+ const attachment = yield* Schema.decodeEffect(WebSocketAttachmentSchema)(conn.deserializeAttachment())
151
+
152
+ // We're doing something a bit "advanced" here as we're directly emitting Effect RPC-compatible
153
+ // response messsages on the Effect RPC-managed websocket connection to the WS client.
154
+ // For this we need to get the RPC `requestId` from the WebSocket attachment.
155
+ for (const requestId of attachment.pullRequestIds) {
156
+ const res: RpcMessage.ResponseChunkEncoded = {
157
+ _tag: 'Chunk',
158
+ requestId,
159
+ values: [encoded],
160
+ }
161
+ conn.send(jsonStringify(res))
160
162
  }
161
- conn.send(jsonStringify(res))
162
163
  }
163
164
  }
165
+
166
+ yield* Effect.logDebug(`Broadcasted to ${connectedClients.length} WebSocket clients`)
164
167
  }
165
168
 
166
- yield* Effect.logDebug(`Broadcasted to ${connectedClients.length} WebSocket clients`)
167
- }
168
-
169
- // RPC broadcasting would require reconstructing client stubs from clientIds
170
- if (rpcSubscriptions.size > 0) {
171
- for (const subscription of rpcSubscriptions.values()) {
172
- for (const { encoded } of responses) {
173
- yield* emitStreamResponse({
174
- callerContext: subscription.callerContext,
175
- env,
176
- requestId: subscription.requestId,
177
- values: [encoded],
178
- }).pipe(Effect.tapCauseLogPretty, Effect.exit)
169
+ // Subscribers live in the DO's KV storage (single source of truth), so fan-out survives reconstruction.
170
+ // emitStreamResponse reconstructs each client stub from its callerContext.
171
+ const rpcSubscriptions = Array.from(
172
+ ctx.storage.kv.list<RpcSubscription>({ prefix: rpcSubscriptionKeyPrefix }),
173
+ )
174
+ if (rpcSubscriptions.length > 0) {
175
+ for (const [, subscription] of rpcSubscriptions) {
176
+ for (const { encoded } of responses) {
177
+ yield* emitStreamResponse({
178
+ callerContext: subscription.callerContext,
179
+ env,
180
+ requestId: subscription.requestId,
181
+ storeId: subscription.storeId,
182
+ values: [encoded],
183
+ }).pipe(Effect.tapCauseLogPretty, Effect.exit)
184
+ }
179
185
  }
180
- }
181
186
 
182
- yield* Effect.logDebug(`Broadcasted to ${rpcSubscriptions.size} RPC clients`)
183
- }
184
- }).pipe(
185
- Effect.tapCauseLogPretty,
186
- Effect.withSpan('push-rpc-broadcast'),
187
- Effect.uninterruptible, // We need to make sure Effect RPC doesn't interrupt this fiber
188
- Effect.fork,
187
+ yield* Effect.logDebug(`Broadcasted to ${rpcSubscriptions.length} RPC clients`)
188
+ }
189
+ }).pipe(Effect.tapCauseLogPretty, Effect.withSpan('push-rpc-broadcast')),
189
190
  )
190
191
 
191
- // We need to yield here to make sure the fork above is kicked off before we let Effect RPC finish the request
192
- yield* Effect.yieldNow()
193
-
192
+ // Acknowledge only after the committed batch has been published, so a client cannot observe an
193
+ // advanced server head while the corresponding pull response is still waiting in a detached fiber.
194
194
  return SyncMessage.PushAck.make({})
195
195
  }).pipe(
196
196
  Effect.tap(
197
197
  Effect.fn(function* (message) {
198
198
  if (options?.onPushRes !== undefined) {
199
- yield* Effect.tryAll(() => options.onPushRes!(message)).pipe(UnknownError.mapToUnknownError)
199
+ yield* Effect.trySyncOrPromiseOrEffect(() => options.onPushRes!(message)).pipe(
200
+ UnknownError.mapToUnknownError,
201
+ )
200
202
  }
201
203
  }),
202
204
  ),
@@ -209,15 +211,36 @@ export const makePush =
209
211
  )
210
212
 
211
213
  /**
214
+ * Serializes the complete push admission lifecycle, including publication to pull subscribers.
215
+ *
216
+ * This complements `blockConcurrencyWhile`: that Cloudflare primitive makes the durable state transition
217
+ * atomic with respect to other Durable Object requests, whereas this semaphore preserves push order after
218
+ * the storage gate is released and broadcasting begins. Keeping broadcast outside `blockConcurrencyWhile`
219
+ * avoids holding Cloudflare's concurrency gate across network I/O.
220
+ *
221
+ * Once admitted, the effect is uninterruptible so cancellation cannot leave a committed head without its
222
+ * matching pull response. Such a gap would make later pushers receive `ServerAhead` while waiting for a
223
+ * response that was never published.
224
+ */
225
+ export const runSerializedPushAdmission = <A, E, R>(
226
+ semaphore: DoCtx.Service['pushSemaphore'],
227
+ admission: Effect.Effect<A, E, R>,
228
+ ): Effect.Effect<A, E, R> => semaphore.withPermits(1)(admission.pipe(Effect.uninterruptible))
229
+
230
+ /**
231
+ * Runs the storage-backed state transition behind Cloudflare's Durable Object concurrency gate.
232
+ * This scope should remain limited to local persistence and head mutation; ordered publication is handled
233
+ * separately by `runSerializedPushAdmission` so outbound work does not block unrelated Durable Object events.
234
+ *
212
235
  * @see https://developers.cloudflare.com/durable-objects/api/state/#blockconcurrencywhile
213
236
  */
214
237
  const blockConcurrencyWhile =
215
238
  (ctx: CfTypes.DurableObjectState) =>
216
239
  <A, E, R>(eff: Effect.Effect<A, E, R>) =>
217
240
  Effect.gen(function* () {
218
- const runtime = yield* Effect.runtime<R>()
241
+ const services = yield* Effect.context<R>()
219
242
  const exit = yield* Effect.promise(() =>
220
- ctx.blockConcurrencyWhile(() => eff.pipe(Effect.provide(runtime), Effect.runPromiseExit)),
243
+ ctx.blockConcurrencyWhile(() => eff.pipe(Effect.runPromiseExitWith(services))),
221
244
  )
222
245
 
223
246
  return yield* exit
@@ -15,7 +15,7 @@ export const eventlogTable = State.SQLite.table({
15
15
  seqNum: State.SQLite.integer({ primaryKey: true, schema: EventSequenceNumber.Global.Schema }),
16
16
  parentSeqNum: State.SQLite.integer({ schema: EventSequenceNumber.Global.Schema }),
17
17
  name: State.SQLite.text({}),
18
- args: State.SQLite.text({ schema: Schema.parseJson(Schema.Any), nullable: true }),
18
+ args: State.SQLite.text({ schema: Schema.fromJsonString(Schema.Any), nullable: true }),
19
19
  /** ISO date format. Currently only used for debugging purposes. */
20
20
  createdAt: State.SQLite.text({}),
21
21
  clientId: State.SQLite.text({}),
@@ -1,12 +1,14 @@
1
1
  import { UnknownError } from '@livestore/common'
2
2
  import type { CfTypes } from '@livestore/common-cf'
3
3
  import type { LiveStoreEvent } from '@livestore/common/schema'
4
- import { Chunk, Effect, Option, Schema, Stream } from '@livestore/utils/effect'
4
+ import { Effect, Option, ReadonlyArray as EffectArray, Schema, Stream } from '@livestore/utils/effect'
5
5
 
6
6
  import { SyncMetadata } from '../../common/sync-message-types.ts'
7
7
  import { PERSISTENCE_FORMAT_VERSION, type StoreId } from '../shared.ts'
8
8
  import { eventlogTable } from './sqlite.ts'
9
9
 
10
+ export type StorageEngine = { _tag: 'd1'; db: CfTypes.D1Database } | { _tag: 'do-sqlite' }
11
+
10
12
  export type SyncStorage = {
11
13
  dbName: string
12
14
  getEvents: (cursor: number | undefined) => Effect.Effect<
@@ -26,11 +28,7 @@ export type SyncStorage = {
26
28
  resetStore: Effect.Effect<void, UnknownError>
27
29
  }
28
30
 
29
- export const makeStorage = (
30
- ctx: CfTypes.DurableObjectState,
31
- storeId: StoreId,
32
- engine: { _tag: 'd1'; db: CfTypes.D1Database } | { _tag: 'do-sqlite' },
33
- ): SyncStorage => {
31
+ export const makeStorage = (ctx: CfTypes.DurableObjectState, storeId: StoreId, engine: StorageEngine): SyncStorage => {
34
32
  const dbName = `eventlog_${PERSISTENCE_FORMAT_VERSION}_${toValidTableName(storeId)}`
35
33
 
36
34
  const execDb = <T>(cb: (db: CfTypes.D1Database) => Promise<CfTypes.D1Result<T>>) =>
@@ -52,7 +50,7 @@ export const makeStorage = (
52
50
  const D1_MIN_PAGE_SIZE = 1
53
51
 
54
52
  const decodeEventlogRows = Schema.decodeUnknownSync(Schema.Array(eventlogTable.rowSchema))
55
- const jsonStringify = Schema.encodeSync(Schema.parseJson())
53
+ const jsonStringify = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))
56
54
  const textEncoder = new TextEncoder()
57
55
 
58
56
  const decreaseLimit = (limit: number) => Math.max(D1_MIN_PAGE_SIZE, Math.floor(limit / 2))
@@ -104,7 +102,7 @@ export const makeStorage = (
104
102
 
105
103
  const fetchPage = (
106
104
  state: State,
107
- ): Effect.Effect<Option.Option<readonly [Chunk.Chunk<EmittedEvent>, State]>, UnknownError> =>
105
+ ): Effect.Effect<readonly [ReadonlyArray<EmittedEvent>, Option.Option<State>], UnknownError> =>
108
106
  Effect.gen(function* () {
109
107
  const statement =
110
108
  state.cursor === undefined
@@ -119,7 +117,8 @@ export const makeStorage = (
119
117
  })
120
118
 
121
119
  if (rawEvents.length === 0) {
122
- return Option.none()
120
+ const done: readonly [ReadonlyArray<EmittedEvent>, Option.Option<State>] = [[], Option.none()]
121
+ return done
123
122
  }
124
123
 
125
124
  const encodedSize = textEncoder.encode(jsonStringify(rawEvents)).byteLength
@@ -132,20 +131,26 @@ export const makeStorage = (
132
131
  }
133
132
  }
134
133
 
135
- 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
+ }
136
140
 
137
- const eventsChunk = Chunk.map(decodedRows, ({ createdAt, ...eventEncoded }) => ({
141
+ const events = decodedRows.map(({ createdAt, ...eventEncoded }) => ({
138
142
  eventEncoded,
139
143
  metadata: Option.some(SyncMetadata.make({ createdAt })),
140
144
  }))
141
145
 
142
- const lastSeqNum = Chunk.unsafeLast(decodedRows).seqNum
146
+ const lastSeqNum = EffectArray.lastNonEmpty(decodedRows).seqNum
143
147
  const nextState: State = { cursor: lastSeqNum, limit: computeNextLimit(state.limit, encodedSize) }
144
148
 
145
- return Option.some([eventsChunk, nextState] as const)
149
+ const page: readonly [ReadonlyArray<EmittedEvent>, Option.Option<State>] = [events, Option.some(nextState)]
150
+ return page
146
151
  })
147
152
 
148
- const stream = Stream.unfoldChunkEffect(initialState, fetchPage)
153
+ const stream = Stream.paginate(initialState, fetchPage)
149
154
 
150
155
  return { total, stream }
151
156
  }).pipe(
@@ -241,7 +246,7 @@ export const makeStorage = (
241
246
 
242
247
  const fetchPage = (
243
248
  state: State,
244
- ): Effect.Effect<Option.Option<readonly [Chunk.Chunk<EmittedEvent>, State]>, UnknownError> =>
249
+ ): Effect.Effect<readonly [ReadonlyArray<EmittedEvent>, Option.Option<State>], UnknownError> =>
245
250
  Effect.try({
246
251
  try: () => {
247
252
  const sql =
@@ -258,24 +263,32 @@ export const makeStorage = (
258
263
  for (const row of iter) rows.push(row)
259
264
 
260
265
  if (rows.length === 0) {
261
- return Option.none()
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
262
275
  }
263
276
 
264
- const decodedRows = Chunk.fromIterable(decodeEventlogRows(rows))
265
- const eventsChunk = Chunk.map(decodedRows, ({ createdAt, ...eventEncoded }) => ({
277
+ const events = decodedRows.map(({ createdAt, ...eventEncoded }) => ({
266
278
  eventEncoded,
267
279
  metadata: Option.some(SyncMetadata.make({ createdAt })),
268
280
  }))
269
281
 
270
- const lastSeqNum = Chunk.unsafeLast(decodedRows).seqNum
282
+ const lastSeqNum = EffectArray.lastNonEmpty(decodedRows).seqNum
271
283
  const nextState: State = { cursor: lastSeqNum }
272
284
 
273
- return Option.some([eventsChunk, nextState] as const)
285
+ const page: readonly [ReadonlyArray<EmittedEvent>, Option.Option<State>] = [events, Option.some(nextState)]
286
+ return page
274
287
  },
275
288
  catch: (error) => new UnknownError({ cause: error, payload: { dbName, stage: 'select' } }),
276
289
  })
277
290
 
278
- const stream = Stream.unfoldChunkEffect(initialState, fetchPage)
291
+ const stream = Stream.paginate(initialState, fetchPage)
279
292
 
280
293
  return { total, stream }
281
294
  }).pipe(
@@ -1,53 +1,43 @@
1
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'
14
4
 
15
5
  import { SyncDoRpc } from '../../../common/do-rpc-schema.ts'
16
- import { SyncMessage } from '../../../common/mod.ts'
17
- import { DoCtx, type DoCtxInput } from '../layer.ts'
6
+ import { rpcSubscriptionKeyPrefix, type RpcSubscription } from '../../shared.ts'
7
+ import * as DoCtx from '../layer.ts'
18
8
  import { makeEndingPullStream } from '../pull.ts'
19
9
  import { makePush } from '../push.ts'
20
10
 
21
11
  export interface DoRpcHandlerOptions {
22
12
  payload: Uint8Array<ArrayBuffer>
23
- input: Omit<DoCtxInput, 'from'>
13
+ input: Omit<DoCtx.DoCtxInput, 'from'>
24
14
  }
25
15
 
26
16
  export const createDoRpcHandler = (
27
17
  options: DoRpcHandlerOptions,
28
18
  ): Effect.Effect<Uint8Array<ArrayBuffer> | CfTypes.ReadableStream> =>
29
- Effect.gen(this, function* () {
19
+ Effect.gen({ self: this }, function* () {
30
20
  const { payload, input } = options
31
- // const { rpcSubscriptions, backendId, doOptions, ctx, env } = yield* DoCtx
32
21
 
33
22
  // TODO add admin RPCs
34
23
  const RpcLive = SyncDoRpc.toLayer({
35
- 'SyncDoRpc.Ping': (_req) => {
36
- return Effect.succeed(SyncMessage.Pong.make({}))
37
- },
24
+ 'SyncDoRpc.Ping': () => Effect.void,
38
25
  'SyncDoRpc.Pull': (req, { headers }) =>
39
- Effect.gen(this, function* () {
40
- const { rpcSubscriptions } = yield* DoCtx
26
+ Effect.gen({ self: this }, function* () {
27
+ const { ctx } = yield* DoCtx.DoCtx
41
28
 
42
29
  // TODO rename `req.rpcContext` to something more appropriate
43
30
  if (req.rpcContext !== undefined) {
44
- rpcSubscriptions.set(req.storeId, {
31
+ const subscription: RpcSubscription = {
45
32
  storeId: req.storeId,
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 } : {}),
50
- })
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)
51
41
  }
52
42
 
53
43
  // DO-RPC doesn't have HTTP headers context - headers are undefined
@@ -58,25 +48,27 @@ export const createDoRpcHandler = (
58
48
  ...res,
59
49
  rpcRequestId: Headers.get(headers, 'x-rpc-request-id').pipe(Option.getOrThrow),
60
50
  })),
61
- Stream.provideLayer(DoCtx.Default({ ...input, from: { storeId: req.storeId } })),
51
+ Stream.provide(DoCtx.layer({ ...input, from: { storeId: req.storeId } })),
62
52
  Stream.mapError((cause) =>
63
53
  cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
64
54
  ? cause
65
55
  : new UnknownError({ cause }),
66
56
  ),
67
- Stream.tapErrorCause(Effect.log),
57
+ Stream.tapCause(Effect.log),
68
58
  ),
69
59
  'SyncDoRpc.Push': (req) =>
70
- Effect.gen(this, function* () {
71
- const { doOptions, ctx, env, storeId } = yield* DoCtx
60
+ Effect.gen({ self: this }, function* () {
61
+ const { doOptions, ctx, env, storeId } = yield* DoCtx.DoCtx
72
62
  // DO-RPC doesn't have HTTP headers context - headers are undefined
73
63
  const push = makePush({ storeId, payload: req.payload, headers: undefined, options: doOptions, ctx, env })
74
64
 
75
65
  return yield* push(req)
76
66
  }).pipe(
77
- Effect.provide(DoCtx.Default({ ...input, from: { storeId: req.storeId } })),
67
+ Effect.provide(DoCtx.layer({ ...input, from: { storeId: req.storeId } })),
78
68
  Effect.mapError((cause) =>
79
- cause._tag === 'UnknownError' || cause._tag === 'ServerAheadError' || cause._tag === 'BackendIdMismatchError'
69
+ cause._tag === 'UnknownError' ||
70
+ cause._tag === 'ServerAheadError' ||
71
+ cause._tag === 'BackendIdMismatchError'
80
72
  ? cause
81
73
  : new UnknownError({ cause }),
82
74
  ),
@@ -85,11 +77,8 @@ export const createDoRpcHandler = (
85
77
  })
86
78
 
87
79
  const handler = toDurableObjectHandler(SyncDoRpc, {
88
- layer: Layer.mergeAll(RpcLive, RpcSerialization.layerJson, HttpServer.layerContext).pipe(
89
- Layer.provide(Logger.consoleWithThread('SyncDo')),
90
- Layer.provide(Logger.minimumLogLevel(LogLevel.Debug)),
91
- ),
80
+ layer: RpcLive,
92
81
  })
93
82
 
94
- return yield* handler(payload)
83
+ return yield* handler(payload).pipe(Effect.annotateLogs({ thread: 'SyncDo' }))
95
84
  }).pipe(Effect.withSpan('createDoRpcHandler'))