@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
- package/dist/cf-worker/do/durable-object.js +24 -11
- package/dist/cf-worker/do/durable-object.js.map +1 -1
- package/dist/cf-worker/do/layer.d.ts +22 -18
- package/dist/cf-worker/do/layer.d.ts.map +1 -1
- package/dist/cf-worker/do/layer.js +43 -33
- package/dist/cf-worker/do/layer.js.map +1 -1
- package/dist/cf-worker/do/pull.d.ts +3 -3
- package/dist/cf-worker/do/pull.d.ts.map +1 -1
- package/dist/cf-worker/do/pull.js +17 -18
- package/dist/cf-worker/do/pull.js.map +1 -1
- package/dist/cf-worker/do/push.d.ts +16 -3
- package/dist/cf-worker/do/push.d.ts.map +1 -1
- package/dist/cf-worker/do/push.js +69 -49
- package/dist/cf-worker/do/push.js.map +1 -1
- package/dist/cf-worker/do/push.test.d.ts +2 -0
- package/dist/cf-worker/do/push.test.d.ts.map +1 -0
- package/dist/cf-worker/do/push.test.js +30 -0
- package/dist/cf-worker/do/push.test.js.map +1 -0
- package/dist/cf-worker/do/sqlite.d.ts +34 -46
- package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
- package/dist/cf-worker/do/sqlite.js +1 -1
- package/dist/cf-worker/do/sqlite.js.map +1 -1
- package/dist/cf-worker/do/sync-storage.d.ts +7 -6
- package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
- package/dist/cf-worker/do/sync-storage.js +26 -14
- package/dist/cf-worker/do/sync-storage.js.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts +2 -2
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.js +21 -19
- package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts +2 -2
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/http-rpc-server.js +7 -7
- package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +2 -2
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.js +9 -9
- package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
- package/dist/cf-worker/shared.d.ts +22 -19
- package/dist/cf-worker/shared.d.ts.map +1 -1
- package/dist/cf-worker/shared.js +13 -11
- package/dist/cf-worker/shared.js.map +1 -1
- package/dist/cf-worker/worker.d.ts +6 -6
- package/dist/cf-worker/worker.d.ts.map +1 -1
- package/dist/cf-worker/worker.js +16 -14
- package/dist/cf-worker/worker.js.map +1 -1
- package/dist/client/transport/do-rpc-client.d.ts +13 -4
- package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/do-rpc-client.js +55 -26
- package/dist/client/transport/do-rpc-client.js.map +1 -1
- package/dist/client/transport/http-rpc-client.d.ts +3 -3
- package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/http-rpc-client.js +30 -21
- package/dist/client/transport/http-rpc-client.js.map +1 -1
- package/dist/client/transport/ws-rpc-client.d.ts +3 -3
- package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/ws-rpc-client.js +20 -16
- package/dist/client/transport/ws-rpc-client.js.map +1 -1
- package/dist/common/do-rpc-schema.d.ts +38 -39
- package/dist/common/do-rpc-schema.d.ts.map +1 -1
- package/dist/common/do-rpc-schema.js +3 -3
- package/dist/common/do-rpc-schema.js.map +1 -1
- package/dist/common/http-rpc-schema.d.ts +36 -37
- package/dist/common/http-rpc-schema.d.ts.map +1 -1
- package/dist/common/http-rpc-schema.js +5 -5
- package/dist/common/http-rpc-schema.js.map +1 -1
- package/dist/common/mod.d.ts +4 -5
- package/dist/common/mod.d.ts.map +1 -1
- package/dist/common/mod.js +3 -4
- package/dist/common/mod.js.map +1 -1
- package/dist/common/sync-message-types.d.ts +116 -133
- package/dist/common/sync-message-types.d.ts.map +1 -1
- package/dist/common/sync-message-types.js +26 -14
- package/dist/common/sync-message-types.js.map +1 -1
- package/dist/common/ws-rpc-schema.d.ts +33 -34
- package/dist/common/ws-rpc-schema.d.ts.map +1 -1
- package/dist/common/ws-rpc-schema.js +4 -4
- package/dist/common/ws-rpc-schema.js.map +1 -1
- package/package.json +21 -37
- package/src/cf-worker/do/durable-object.ts +45 -36
- package/src/cf-worker/do/layer.ts +142 -99
- package/src/cf-worker/do/pull.ts +28 -28
- package/src/cf-worker/do/push.test.ts +40 -0
- package/src/cf-worker/do/push.ts +138 -115
- package/src/cf-worker/do/sqlite.ts +1 -1
- package/src/cf-worker/do/sync-storage.ts +34 -21
- package/src/cf-worker/do/transport/do-rpc-server.ts +23 -34
- package/src/cf-worker/do/transport/http-rpc-server.ts +7 -10
- package/src/cf-worker/do/transport/ws-rpc-server.ts +10 -10
- package/src/cf-worker/shared.ts +18 -13
- package/src/cf-worker/worker.ts +21 -22
- package/src/client/transport/do-rpc-client.ts +82 -40
- package/src/client/transport/http-rpc-client.ts +47 -37
- package/src/client/transport/ws-rpc-client.ts +29 -26
- package/src/common/do-rpc-schema.ts +3 -3
- package/src/common/http-rpc-schema.ts +5 -5
- package/src/common/mod.ts +3 -4
- package/src/common/sync-message-types.ts +16 -16
- 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
|
+
})
|
package/src/cf-worker/do/push.ts
CHANGED
|
@@ -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 {
|
|
9
|
-
import {
|
|
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
|
|
17
|
+
import * as DoCtx from './layer.ts'
|
|
21
18
|
|
|
22
|
-
const
|
|
23
|
-
const
|
|
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.
|
|
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,
|
|
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.
|
|
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
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
}
|
|
81
|
+
const createdAt = new Date().toISOString()
|
|
83
82
|
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
yield* storage.appendEvents(pushRequest.batch, createdAt)
|
|
86
|
+
updateCurrentHead(pushRequest.batch.at(-1)!.seqNum)
|
|
88
87
|
|
|
89
|
-
|
|
88
|
+
return { createdAt }
|
|
89
|
+
}).pipe(blockConcurrencyWhile(ctx))
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
}).pipe(blockConcurrencyWhile(ctx))
|
|
91
|
+
const connectedClients = ctx.getWebSockets()
|
|
93
92
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
for (const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
//
|
|
192
|
-
|
|
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.
|
|
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
|
|
241
|
+
const services = yield* Effect.context<R>()
|
|
219
242
|
const exit = yield* Effect.promise(() =>
|
|
220
|
-
ctx.blockConcurrencyWhile(() => eff.pipe(Effect.
|
|
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.
|
|
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 {
|
|
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.
|
|
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<
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
146
|
+
const lastSeqNum = EffectArray.lastNonEmpty(decodedRows).seqNum
|
|
143
147
|
const nextState: State = { cursor: lastSeqNum, limit: computeNextLimit(state.limit, encodedSize) }
|
|
144
148
|
|
|
145
|
-
|
|
149
|
+
const page: readonly [ReadonlyArray<EmittedEvent>, Option.Option<State>] = [events, Option.some(nextState)]
|
|
150
|
+
return page
|
|
146
151
|
})
|
|
147
152
|
|
|
148
|
-
const stream = Stream.
|
|
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<
|
|
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
|
-
|
|
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
|
|
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 =
|
|
282
|
+
const lastSeqNum = EffectArray.lastNonEmpty(decodedRows).seqNum
|
|
271
283
|
const nextState: State = { cursor: lastSeqNum }
|
|
272
284
|
|
|
273
|
-
|
|
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.
|
|
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 {
|
|
17
|
-
import
|
|
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': (
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
67
|
+
Effect.provide(DoCtx.layer({ ...input, from: { storeId: req.storeId } })),
|
|
78
68
|
Effect.mapError((cause) =>
|
|
79
|
-
cause._tag === 'UnknownError' ||
|
|
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:
|
|
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'))
|