@livestore/sync-cf 0.4.0 → 0.5.0-dev.1

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 (134) hide show
  1. package/README.md +5 -5
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
  4. package/dist/cf-worker/do/durable-object.js +53 -36
  5. package/dist/cf-worker/do/durable-object.js.map +1 -1
  6. package/dist/cf-worker/do/layer.d.ts +22 -18
  7. package/dist/cf-worker/do/layer.d.ts.map +1 -1
  8. package/dist/cf-worker/do/layer.js +45 -35
  9. package/dist/cf-worker/do/layer.js.map +1 -1
  10. package/dist/cf-worker/do/observability.d.ts +23 -0
  11. package/dist/cf-worker/do/observability.d.ts.map +1 -0
  12. package/dist/cf-worker/do/observability.js +52 -0
  13. package/dist/cf-worker/do/observability.js.map +1 -0
  14. package/dist/cf-worker/do/observability.test.d.ts +2 -0
  15. package/dist/cf-worker/do/observability.test.d.ts.map +1 -0
  16. package/dist/cf-worker/do/observability.test.js +204 -0
  17. package/dist/cf-worker/do/observability.test.js.map +1 -0
  18. package/dist/cf-worker/do/pull.d.ts +3 -3
  19. package/dist/cf-worker/do/pull.d.ts.map +1 -1
  20. package/dist/cf-worker/do/pull.js +19 -20
  21. package/dist/cf-worker/do/pull.js.map +1 -1
  22. package/dist/cf-worker/do/push.d.ts +16 -3
  23. package/dist/cf-worker/do/push.d.ts.map +1 -1
  24. package/dist/cf-worker/do/push.js +74 -53
  25. package/dist/cf-worker/do/push.js.map +1 -1
  26. package/dist/cf-worker/do/push.test.d.ts +2 -0
  27. package/dist/cf-worker/do/push.test.d.ts.map +1 -0
  28. package/dist/cf-worker/do/push.test.js +30 -0
  29. package/dist/cf-worker/do/push.test.js.map +1 -0
  30. package/dist/cf-worker/do/sqlite.d.ts +34 -46
  31. package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
  32. package/dist/cf-worker/do/sqlite.js +2 -2
  33. package/dist/cf-worker/do/sqlite.js.map +1 -1
  34. package/dist/cf-worker/do/sync-storage.d.ts +7 -6
  35. package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
  36. package/dist/cf-worker/do/sync-storage.js +29 -17
  37. package/dist/cf-worker/do/sync-storage.js.map +1 -1
  38. package/dist/cf-worker/do/transport/do-rpc-server.d.ts +17 -2
  39. package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
  40. package/dist/cf-worker/do/transport/do-rpc-server.js +41 -23
  41. package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
  42. package/dist/cf-worker/do/transport/do-rpc-server.test.d.ts +2 -0
  43. package/dist/cf-worker/do/transport/do-rpc-server.test.d.ts.map +1 -0
  44. package/dist/cf-worker/do/transport/do-rpc-server.test.js +28 -0
  45. package/dist/cf-worker/do/transport/do-rpc-server.test.js.map +1 -0
  46. package/dist/cf-worker/do/transport/http-rpc-server.d.ts +2 -2
  47. package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
  48. package/dist/cf-worker/do/transport/http-rpc-server.js +12 -12
  49. package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
  50. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +9 -2
  51. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
  52. package/dist/cf-worker/do/transport/ws-rpc-server.js +37 -29
  53. package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
  54. package/dist/cf-worker/mod.js +3 -3
  55. package/dist/cf-worker/shared.d.ts +37 -29
  56. package/dist/cf-worker/shared.d.ts.map +1 -1
  57. package/dist/cf-worker/shared.js +14 -12
  58. package/dist/cf-worker/shared.js.map +1 -1
  59. package/dist/cf-worker/worker.d.ts +9 -9
  60. package/dist/cf-worker/worker.d.ts.map +1 -1
  61. package/dist/cf-worker/worker.js +17 -15
  62. package/dist/cf-worker/worker.js.map +1 -1
  63. package/dist/client/mod.js +3 -3
  64. package/dist/client/transport/do-rpc-client.d.ts +18 -6
  65. package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
  66. package/dist/client/transport/do-rpc-client.js +112 -38
  67. package/dist/client/transport/do-rpc-client.js.map +1 -1
  68. package/dist/client/transport/do-rpc-client.test.d.ts +2 -0
  69. package/dist/client/transport/do-rpc-client.test.d.ts.map +1 -0
  70. package/dist/client/transport/do-rpc-client.test.js +268 -0
  71. package/dist/client/transport/do-rpc-client.test.js.map +1 -0
  72. package/dist/client/transport/http-rpc-client.d.ts +3 -3
  73. package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
  74. package/dist/client/transport/http-rpc-client.js +48 -29
  75. package/dist/client/transport/http-rpc-client.js.map +1 -1
  76. package/dist/client/transport/rpc-client-error.d.ts +5 -0
  77. package/dist/client/transport/rpc-client-error.d.ts.map +1 -0
  78. package/dist/client/transport/rpc-client-error.js +8 -0
  79. package/dist/client/transport/rpc-client-error.js.map +1 -0
  80. package/dist/client/transport/rpc-client-error.test.d.ts +2 -0
  81. package/dist/client/transport/rpc-client-error.test.d.ts.map +1 -0
  82. package/dist/client/transport/rpc-client-error.test.js +33 -0
  83. package/dist/client/transport/rpc-client-error.test.js.map +1 -0
  84. package/dist/client/transport/ws-rpc-client.d.ts +3 -3
  85. package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
  86. package/dist/client/transport/ws-rpc-client.js +33 -23
  87. package/dist/client/transport/ws-rpc-client.js.map +1 -1
  88. package/dist/common/do-rpc-schema.d.ts +53 -41
  89. package/dist/common/do-rpc-schema.d.ts.map +1 -1
  90. package/dist/common/do-rpc-schema.js +13 -4
  91. package/dist/common/do-rpc-schema.js.map +1 -1
  92. package/dist/common/http-rpc-schema.d.ts +38 -39
  93. package/dist/common/http-rpc-schema.d.ts.map +1 -1
  94. package/dist/common/http-rpc-schema.js +6 -6
  95. package/dist/common/http-rpc-schema.js.map +1 -1
  96. package/dist/common/mod.d.ts +5 -5
  97. package/dist/common/mod.d.ts.map +1 -1
  98. package/dist/common/mod.js +7 -7
  99. package/dist/common/mod.js.map +1 -1
  100. package/dist/common/sync-message-types.d.ts +116 -133
  101. package/dist/common/sync-message-types.d.ts.map +1 -1
  102. package/dist/common/sync-message-types.js +26 -14
  103. package/dist/common/sync-message-types.js.map +1 -1
  104. package/dist/common/ws-rpc-schema.d.ts +33 -34
  105. package/dist/common/ws-rpc-schema.d.ts.map +1 -1
  106. package/dist/common/ws-rpc-schema.js +5 -5
  107. package/dist/common/ws-rpc-schema.js.map +1 -1
  108. package/package.json +42 -58
  109. package/src/cf-worker/do/durable-object.ts +70 -64
  110. package/src/cf-worker/do/layer.ts +142 -99
  111. package/src/cf-worker/do/observability.test.ts +337 -0
  112. package/src/cf-worker/do/observability.ts +121 -0
  113. package/src/cf-worker/do/pull.ts +28 -28
  114. package/src/cf-worker/do/push.test.ts +40 -0
  115. package/src/cf-worker/do/push.ts +139 -115
  116. package/src/cf-worker/do/sqlite.ts +1 -1
  117. package/src/cf-worker/do/sync-storage.ts +34 -21
  118. package/src/cf-worker/do/transport/do-rpc-server.test.ts +32 -0
  119. package/src/cf-worker/do/transport/do-rpc-server.ts +46 -35
  120. package/src/cf-worker/do/transport/http-rpc-server.ts +7 -10
  121. package/src/cf-worker/do/transport/ws-rpc-server.ts +52 -28
  122. package/src/cf-worker/shared.ts +29 -17
  123. package/src/cf-worker/worker.ts +21 -22
  124. package/src/client/transport/do-rpc-client.test.ts +411 -0
  125. package/src/client/transport/do-rpc-client.ts +175 -51
  126. package/src/client/transport/http-rpc-client.ts +66 -45
  127. package/src/client/transport/rpc-client-error.test.ts +41 -0
  128. package/src/client/transport/rpc-client-error.ts +9 -0
  129. package/src/client/transport/ws-rpc-client.ts +40 -32
  130. package/src/common/do-rpc-schema.ts +13 -3
  131. package/src/common/http-rpc-schema.ts +5 -5
  132. package/src/common/mod.ts +4 -4
  133. package/src/common/sync-message-types.ts +16 -16
  134. package/src/common/ws-rpc-schema.ts +4 -4
@@ -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,95 @@ 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 { response } of responses) {
177
+ yield* emitStreamResponse({
178
+ callerContext: subscription.callerContext,
179
+ env,
180
+ requestId: subscription.requestId,
181
+ storeId: subscription.storeId,
182
+ schema: SyncMessage.PullResponse,
183
+ values: [response],
184
+ }).pipe(Effect.tapCauseLogPretty, Effect.exit)
185
+ }
179
186
  }
180
- }
181
187
 
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,
188
+ yield* Effect.logDebug(`Broadcasted to ${rpcSubscriptions.length} RPC clients`)
189
+ }
190
+ }).pipe(Effect.tapCauseLogPretty, Effect.withSpan('push-rpc-broadcast')),
189
191
  )
190
192
 
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
-
193
+ // Acknowledge only after the committed batch has been published, so a client cannot observe an
194
+ // advanced server head while the corresponding pull response is still waiting in a detached fiber.
194
195
  return SyncMessage.PushAck.make({})
195
196
  }).pipe(
196
197
  Effect.tap(
197
198
  Effect.fn(function* (message) {
198
199
  if (options?.onPushRes !== undefined) {
199
- yield* Effect.tryAll(() => options.onPushRes!(message)).pipe(UnknownError.mapToUnknownError)
200
+ yield* Effect.trySyncOrPromiseOrEffect(() => options.onPushRes!(message)).pipe(
201
+ UnknownError.mapToUnknownError,
202
+ )
200
203
  }
201
204
  }),
202
205
  ),
@@ -209,15 +212,36 @@ export const makePush =
209
212
  )
210
213
 
211
214
  /**
215
+ * Serializes the complete push admission lifecycle, including publication to pull subscribers.
216
+ *
217
+ * This complements `blockConcurrencyWhile`: that Cloudflare primitive makes the durable state transition
218
+ * atomic with respect to other Durable Object requests, whereas this semaphore preserves push order after
219
+ * the storage gate is released and broadcasting begins. Keeping broadcast outside `blockConcurrencyWhile`
220
+ * avoids holding Cloudflare's concurrency gate across network I/O.
221
+ *
222
+ * Once admitted, the effect is uninterruptible so cancellation cannot leave a committed head without its
223
+ * matching pull response. Such a gap would make later pushers receive `ServerAhead` while waiting for a
224
+ * response that was never published.
225
+ */
226
+ export const runSerializedPushAdmission = <A, E, R>(
227
+ semaphore: DoCtx.Service['pushSemaphore'],
228
+ admission: Effect.Effect<A, E, R>,
229
+ ): Effect.Effect<A, E, R> => semaphore.withPermits(1)(admission.pipe(Effect.uninterruptible))
230
+
231
+ /**
232
+ * Runs the storage-backed state transition behind Cloudflare's Durable Object concurrency gate.
233
+ * This scope should remain limited to local persistence and head mutation; ordered publication is handled
234
+ * separately by `runSerializedPushAdmission` so outbound work does not block unrelated Durable Object events.
235
+ *
212
236
  * @see https://developers.cloudflare.com/durable-objects/api/state/#blockconcurrencywhile
213
237
  */
214
238
  const blockConcurrencyWhile =
215
239
  (ctx: CfTypes.DurableObjectState) =>
216
240
  <A, E, R>(eff: Effect.Effect<A, E, R>) =>
217
241
  Effect.gen(function* () {
218
- const runtime = yield* Effect.runtime<R>()
242
+ const services = yield* Effect.context<R>()
219
243
  const exit = yield* Effect.promise(() =>
220
- ctx.blockConcurrencyWhile(() => eff.pipe(Effect.provide(runtime), Effect.runPromiseExit)),
244
+ ctx.blockConcurrencyWhile(() => eff.pipe(Effect.runPromiseExitWith(services))),
221
245
  )
222
246
 
223
247
  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(
@@ -0,0 +1,32 @@
1
+ import { Vitest } from '@livestore/utils-dev/node-vitest'
2
+
3
+ import { type RpcSubscription, rpcSubscriptionKeyPrefix } from '../../shared.ts'
4
+ import { dropRpcSubscription } from './do-rpc-server.ts'
5
+
6
+ const durableObjectId = 'client-do'
7
+ const key = `${rpcSubscriptionKeyPrefix}${durableObjectId}`
8
+
9
+ const subscribedWith = (requestId: string) => {
10
+ const rows = new Map<string, RpcSubscription>([
11
+ [
12
+ key,
13
+ { storeId: 'store', subscribedAt: 0, requestId, callerContext: { bindingName: 'CLIENT_DO', durableObjectId } },
14
+ ],
15
+ ])
16
+ return { rows, kv: { get: (k: string) => rows.get(k), delete: (k: string) => rows.delete(k) } }
17
+ }
18
+
19
+ Vitest.describe('sync-cf DO-RPC unsubscribe', () => {
20
+ // A replacement pull overwrote the row with its own request id; the earlier pull's late unsubscribe must be a no-op.
21
+ Vitest.test('keeps the row when the request id was superseded', () => {
22
+ const { rows, kv } = subscribedWith('pull-2')
23
+ dropRpcSubscription(kv, { durableObjectId, requestId: 'pull-1' })
24
+ Vitest.expect(rows.has(key)).toBe(true)
25
+ })
26
+
27
+ Vitest.test('drops the row for the pull that registered it', () => {
28
+ const { rows, kv } = subscribedWith('pull-1')
29
+ dropRpcSubscription(kv, { durableObjectId, requestId: 'pull-1' })
30
+ Vitest.expect(rows.has(key)).toBe(false)
31
+ })
32
+ })