@livestore/sync-cf 0.3.0-dev.16 → 0.3.0-dev.18
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/durable-object.d.ts +3 -0
- package/dist/cf-worker/durable-object.d.ts.map +1 -1
- package/dist/cf-worker/durable-object.js +119 -93
- package/dist/cf-worker/durable-object.js.map +1 -1
- package/dist/common/ws-message-types.d.ts +13 -0
- package/dist/common/ws-message-types.d.ts.map +1 -1
- package/dist/common/ws-message-types.js +1 -0
- package/dist/common/ws-message-types.js.map +1 -1
- package/dist/sync-impl/ws-impl.d.ts.map +1 -1
- package/dist/sync-impl/ws-impl.js +50 -2
- package/dist/sync-impl/ws-impl.js.map +1 -1
- package/package.json +3 -3
- package/src/cf-worker/durable-object.ts +74 -38
- package/src/common/ws-message-types.ts +3 -0
- package/src/sync-impl/ws-impl.ts +56 -1
|
@@ -36,6 +36,8 @@ const WebSocketAttachmentSchema = Schema.parseJson(
|
|
|
36
36
|
}),
|
|
37
37
|
)
|
|
38
38
|
|
|
39
|
+
export const PULL_CHUNK_SIZE = 100
|
|
40
|
+
|
|
39
41
|
/**
|
|
40
42
|
* Needs to be bumped when the storage format changes (e.g. mutationLogTable schema changes)
|
|
41
43
|
*
|
|
@@ -45,7 +47,9 @@ export const PERSISTENCE_FORMAT_VERSION = 3
|
|
|
45
47
|
|
|
46
48
|
export type MakeDurableObjectClassOptions = {
|
|
47
49
|
onPush?: (message: WSMessage.PushReq) => Effect.Effect<void> | Promise<void>
|
|
50
|
+
onPushRes?: (message: WSMessage.PushAck | WSMessage.Error) => Effect.Effect<void> | Promise<void>
|
|
48
51
|
onPull?: (message: WSMessage.PullReq) => Effect.Effect<void> | Promise<void>
|
|
52
|
+
onPullRes?: (message: WSMessage.PullRes | WSMessage.Error) => Effect.Effect<void> | Promise<void>
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
export type MakeDurableObjectClass = (options?: MakeDurableObjectClassOptions) => {
|
|
@@ -54,6 +58,9 @@ export type MakeDurableObjectClass = (options?: MakeDurableObjectClassOptions) =
|
|
|
54
58
|
|
|
55
59
|
export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
56
60
|
return class WebSocketServerBase extends DurableObject<Env> {
|
|
61
|
+
/** Needed to prevent concurrent pushes */
|
|
62
|
+
private pushSemaphore = Effect.makeSemaphore(1).pipe(Effect.runSync)
|
|
63
|
+
|
|
57
64
|
constructor(ctx: DurableObjectState, env: Env) {
|
|
58
65
|
super(ctx, env)
|
|
59
66
|
}
|
|
@@ -88,21 +95,21 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
88
95
|
})
|
|
89
96
|
}).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
|
|
90
97
|
|
|
91
|
-
webSocketMessage = (ws: WebSocketClient, message: ArrayBuffer | string) =>
|
|
92
|
-
|
|
93
|
-
const decodedMessageRes = decodeIncomingMessage(message)
|
|
98
|
+
webSocketMessage = (ws: WebSocketClient, message: ArrayBuffer | string) => {
|
|
99
|
+
const decodedMessageRes = decodeIncomingMessage(message)
|
|
94
100
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
if (decodedMessageRes._tag === 'Left') {
|
|
102
|
+
console.error('Invalid message received', decodedMessageRes.left)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const decodedMessage = decodedMessageRes.right
|
|
107
|
+
const requestId = decodedMessage.requestId
|
|
99
108
|
|
|
109
|
+
return Effect.gen(this, function* () {
|
|
100
110
|
const { storeId } = yield* Schema.decode(WebSocketAttachmentSchema)(ws.deserializeAttachment())
|
|
101
111
|
const storage = makeStorage(this.ctx, this.env, storeId)
|
|
102
112
|
|
|
103
|
-
const decodedMessage = decodedMessageRes.right
|
|
104
|
-
const requestId = decodedMessage.requestId
|
|
105
|
-
|
|
106
113
|
try {
|
|
107
114
|
switch (decodedMessage._tag) {
|
|
108
115
|
// TODO allow pulling concurrently to not block incoming push requests
|
|
@@ -111,8 +118,15 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
111
118
|
yield* Effect.tryAll(() => options.onPull!(decodedMessage))
|
|
112
119
|
}
|
|
113
120
|
|
|
121
|
+
const respond = (message: WSMessage.PullRes) =>
|
|
122
|
+
Effect.gen(function* () {
|
|
123
|
+
if (options?.onPullRes) {
|
|
124
|
+
yield* Effect.tryAll(() => options.onPullRes!(message))
|
|
125
|
+
}
|
|
126
|
+
ws.send(encodeOutgoingMessage(message))
|
|
127
|
+
})
|
|
128
|
+
|
|
114
129
|
const cursor = decodedMessage.cursor
|
|
115
|
-
const CHUNK_SIZE = 100
|
|
116
130
|
|
|
117
131
|
// TODO use streaming
|
|
118
132
|
const remainingEvents = yield* storage.getEvents(cursor)
|
|
@@ -121,32 +135,41 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
121
135
|
const batches =
|
|
122
136
|
remainingEvents.length === 0
|
|
123
137
|
? [[]]
|
|
124
|
-
: Array.from({ length: Math.ceil(remainingEvents.length /
|
|
125
|
-
remainingEvents.slice(i *
|
|
138
|
+
: Array.from({ length: Math.ceil(remainingEvents.length / PULL_CHUNK_SIZE) }, (_, i) =>
|
|
139
|
+
remainingEvents.slice(i * PULL_CHUNK_SIZE, (i + 1) * PULL_CHUNK_SIZE),
|
|
126
140
|
)
|
|
127
141
|
|
|
128
142
|
for (const [index, batch] of batches.entries()) {
|
|
129
|
-
const remaining = Math.max(0, remainingEvents.length - (index + 1) *
|
|
130
|
-
|
|
143
|
+
const remaining = Math.max(0, remainingEvents.length - (index + 1) * PULL_CHUNK_SIZE)
|
|
144
|
+
yield* respond(WSMessage.PullRes.make({ batch, remaining, requestId: { context: 'pull', requestId } }))
|
|
131
145
|
}
|
|
132
146
|
|
|
133
147
|
break
|
|
134
148
|
}
|
|
135
149
|
case 'WSMessage.PushReq': {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
150
|
+
const respond = (message: WSMessage.PushAck | WSMessage.Error) =>
|
|
151
|
+
Effect.gen(function* () {
|
|
152
|
+
if (options?.onPushRes) {
|
|
153
|
+
yield* Effect.tryAll(() => options.onPushRes!(message))
|
|
154
|
+
}
|
|
155
|
+
ws.send(encodeOutgoingMessage(message))
|
|
156
|
+
})
|
|
139
157
|
|
|
140
158
|
if (decodedMessage.batch.length === 0) {
|
|
141
|
-
|
|
159
|
+
yield* respond(WSMessage.PushAck.make({ requestId }))
|
|
142
160
|
return
|
|
143
161
|
}
|
|
144
162
|
|
|
163
|
+
yield* this.pushSemaphore.take(1)
|
|
164
|
+
|
|
165
|
+
if (options?.onPush) {
|
|
166
|
+
yield* Effect.tryAll(() => options.onPush!(decodedMessage))
|
|
167
|
+
}
|
|
168
|
+
|
|
145
169
|
// TODO check whether we could use the Durable Object storage for this to speed up the lookup
|
|
146
170
|
const expectedParentId = yield* storage.getHead
|
|
147
171
|
|
|
148
172
|
// TODO handle clientId unique conflict
|
|
149
|
-
|
|
150
173
|
// Validate the batch
|
|
151
174
|
const firstEvent = decodedMessage.batch[0]!
|
|
152
175
|
if (firstEvent.parentId !== expectedParentId) {
|
|
@@ -157,11 +180,14 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
157
180
|
|
|
158
181
|
yield* Effect.logError(err)
|
|
159
182
|
|
|
160
|
-
|
|
183
|
+
yield* respond(err)
|
|
184
|
+
yield* this.pushSemaphore.release(1)
|
|
161
185
|
return
|
|
162
186
|
}
|
|
163
187
|
|
|
164
|
-
|
|
188
|
+
yield* respond(WSMessage.PushAck.make({ requestId }))
|
|
189
|
+
|
|
190
|
+
yield* this.pushSemaphore.release(1)
|
|
165
191
|
|
|
166
192
|
const createdAt = new Date().toISOString()
|
|
167
193
|
|
|
@@ -172,22 +198,26 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
172
198
|
const connectedClients = this.ctx.getWebSockets()
|
|
173
199
|
|
|
174
200
|
// console.debug(`Broadcasting push batch to ${this.subscribedWebSockets.size} clients`)
|
|
175
|
-
|
|
176
201
|
if (connectedClients.length > 0) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
)
|
|
202
|
+
// TODO refactor to batch api
|
|
203
|
+
const pullRes = WSMessage.PullRes.make({
|
|
204
|
+
batch: decodedMessage.batch.map((mutationEventEncoded) => ({
|
|
205
|
+
mutationEventEncoded,
|
|
206
|
+
metadata: Option.some({ createdAt }),
|
|
207
|
+
})),
|
|
208
|
+
remaining: 0,
|
|
209
|
+
requestId: { context: 'push', requestId },
|
|
210
|
+
})
|
|
211
|
+
const pullResEnc = encodeOutgoingMessage(pullRes)
|
|
212
|
+
|
|
213
|
+
// Only calling once for now.
|
|
214
|
+
if (options?.onPullRes) {
|
|
215
|
+
yield* Effect.tryAll(() => options.onPullRes!(pullRes))
|
|
216
|
+
}
|
|
187
217
|
|
|
188
218
|
// NOTE we're also sending the pullRes to the pushing ws client as a confirmation
|
|
189
219
|
for (const conn of connectedClients) {
|
|
190
|
-
conn.send(
|
|
220
|
+
conn.send(pullResEnc)
|
|
191
221
|
}
|
|
192
222
|
}
|
|
193
223
|
|
|
@@ -230,12 +260,15 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
230
260
|
ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: error.message, requestId })))
|
|
231
261
|
}
|
|
232
262
|
}).pipe(
|
|
233
|
-
Effect.withSpan(
|
|
263
|
+
Effect.withSpan(`@livestore/sync-cf:durable-object:webSocketMessage:${decodedMessage._tag}`, {
|
|
264
|
+
attributes: { requestId },
|
|
265
|
+
}),
|
|
234
266
|
Effect.tapCauseLogPretty,
|
|
235
267
|
Logger.withMinimumLogLevel(LogLevel.Debug),
|
|
236
268
|
Effect.provide(Logger.pretty),
|
|
237
269
|
Effect.runPromise,
|
|
238
270
|
)
|
|
271
|
+
}
|
|
239
272
|
|
|
240
273
|
webSocketClose = async (ws: WebSocketClient, code: number, _reason: string, _wasClean: boolean) => {
|
|
241
274
|
// If the client closes the connection, the runtime will invoke the webSocketClose() handler.
|
|
@@ -267,7 +300,10 @@ const makeStorage = (ctx: DurableObjectState, env: Env, storeId: string): SyncSt
|
|
|
267
300
|
Effect.tryPromise({
|
|
268
301
|
try: () => cb(env.DB),
|
|
269
302
|
catch: (error) => new UnexpectedError({ cause: error, payload: { dbName } }),
|
|
270
|
-
}).pipe(
|
|
303
|
+
}).pipe(
|
|
304
|
+
Effect.map((_) => _.results),
|
|
305
|
+
Effect.withSpan('@livestore/sync-cf:durable-object:execDb'),
|
|
306
|
+
)
|
|
271
307
|
|
|
272
308
|
const getHead: Effect.Effect<EventId.GlobalEventId, UnexpectedError> = Effect.gen(function* () {
|
|
273
309
|
const result = yield* execDb<{ id: EventId.GlobalEventId }>((db) =>
|
|
@@ -295,7 +331,7 @@ const makeStorage = (ctx: DurableObjectState, env: Env, storeId: string): SyncSt
|
|
|
295
331
|
}),
|
|
296
332
|
)
|
|
297
333
|
return events
|
|
298
|
-
})
|
|
334
|
+
}).pipe(UnexpectedError.mapToUnexpectedError)
|
|
299
335
|
|
|
300
336
|
const appendEvents: SyncStorage['appendEvents'] = (batch, createdAt) =>
|
|
301
337
|
Effect.gen(function* () {
|
|
@@ -330,7 +366,7 @@ const makeStorage = (ctx: DurableObjectState, env: Env, storeId: string): SyncSt
|
|
|
330
366
|
.run(),
|
|
331
367
|
)
|
|
332
368
|
}
|
|
333
|
-
})
|
|
369
|
+
}).pipe(UnexpectedError.mapToUnexpectedError)
|
|
334
370
|
|
|
335
371
|
const resetStore = Effect.gen(function* () {
|
|
336
372
|
yield* Effect.promise(() => ctx.storage.deleteAll())
|
|
@@ -23,6 +23,7 @@ export const PullRes = Schema.TaggedStruct('WSMessage.PullRes', {
|
|
|
23
23
|
metadata: Schema.Option(SyncMetadata),
|
|
24
24
|
}),
|
|
25
25
|
),
|
|
26
|
+
requestId: Schema.Struct({ context: Schema.Literal('pull', 'push'), requestId: Schema.String }),
|
|
26
27
|
remaining: Schema.Number,
|
|
27
28
|
})
|
|
28
29
|
|
|
@@ -46,6 +47,8 @@ export const Error = Schema.TaggedStruct('WSMessage.Error', {
|
|
|
46
47
|
message: Schema.String,
|
|
47
48
|
})
|
|
48
49
|
|
|
50
|
+
export type Error = typeof Error.Type
|
|
51
|
+
|
|
49
52
|
export const Ping = Schema.TaggedStruct('WSMessage.Ping', {
|
|
50
53
|
requestId: Schema.Literal('ping'),
|
|
51
54
|
})
|
package/src/sync-impl/ws-impl.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import type { SyncBackend } from '@livestore/common'
|
|
4
4
|
import { InvalidPullError, InvalidPushError } from '@livestore/common'
|
|
5
|
-
import {
|
|
5
|
+
import { EventId } from '@livestore/common/schema'
|
|
6
|
+
import { LS_DEV, shouldNeverHappen } from '@livestore/utils'
|
|
6
7
|
import type { Scope } from '@livestore/utils/effect'
|
|
7
8
|
import {
|
|
8
9
|
Deferred,
|
|
@@ -33,10 +34,29 @@ export const makeWsSync = (options: WsSyncOptions): Effect.Effect<SyncBackend<Sy
|
|
|
33
34
|
|
|
34
35
|
const { isConnected, incomingMessages, send } = yield* connect(wsUrl)
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* We need to account for the scenario where push-caused PullRes message arrive before the pull-caused PullRes message.
|
|
39
|
+
* i.e. a scenario where the WS connection is created but before the server processed the initial pull, a push from
|
|
40
|
+
* another client triggers a PullRes message sent to this client which we need to stash until our pull-caused
|
|
41
|
+
* PullRes message arrives at which point we can combine the stashed events with the pull-caused events and continue.
|
|
42
|
+
*/
|
|
43
|
+
const stashedPullBatch: WSMessage.PullRes['batch'][number][] = []
|
|
44
|
+
|
|
45
|
+
// We currently only support one pull stream for a sync backend.
|
|
46
|
+
let pullStarted = false
|
|
47
|
+
|
|
36
48
|
const api = {
|
|
37
49
|
isConnected,
|
|
38
50
|
pull: (args) =>
|
|
39
51
|
Effect.gen(function* () {
|
|
52
|
+
if (pullStarted) {
|
|
53
|
+
return shouldNeverHappen(`Pull already started for this sync backend.`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
pullStarted = true
|
|
57
|
+
|
|
58
|
+
let pullResponseReceived = false
|
|
59
|
+
|
|
40
60
|
const requestId = nanoid()
|
|
41
61
|
const cursor = Option.getOrUndefined(args)?.cursor.global
|
|
42
62
|
|
|
@@ -48,6 +68,41 @@ export const makeWsSync = (options: WsSyncOptions): Effect.Effect<SyncBackend<Sy
|
|
|
48
68
|
? new InvalidPullError({ message: _.message })
|
|
49
69
|
: Effect.void,
|
|
50
70
|
),
|
|
71
|
+
Stream.filterMap((msg) => {
|
|
72
|
+
if (msg._tag === 'WSMessage.PullRes') {
|
|
73
|
+
if (msg.requestId.context === 'pull') {
|
|
74
|
+
if (msg.requestId.requestId === requestId) {
|
|
75
|
+
pullResponseReceived = true
|
|
76
|
+
|
|
77
|
+
if (stashedPullBatch.length > 0 && msg.remaining === 0) {
|
|
78
|
+
const pullResHead = msg.batch.at(-1)?.mutationEventEncoded.id ?? EventId.ROOT.global
|
|
79
|
+
// Index where stashed events are greater than pullResHead
|
|
80
|
+
const newPartialBatchIndex = stashedPullBatch.findIndex(
|
|
81
|
+
(batchItem) => batchItem.mutationEventEncoded.id > pullResHead,
|
|
82
|
+
)
|
|
83
|
+
const batchWithNewStashedEvents =
|
|
84
|
+
newPartialBatchIndex === -1 ? [] : stashedPullBatch.slice(newPartialBatchIndex)
|
|
85
|
+
const combinedBatch = [...msg.batch, ...batchWithNewStashedEvents]
|
|
86
|
+
return Option.some({ ...msg, batch: combinedBatch, remaining: 0 })
|
|
87
|
+
} else {
|
|
88
|
+
return Option.some(msg)
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
// Ignore
|
|
92
|
+
return Option.none()
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
if (pullResponseReceived) {
|
|
96
|
+
return Option.some(msg)
|
|
97
|
+
} else {
|
|
98
|
+
stashedPullBatch.push(...msg.batch)
|
|
99
|
+
return Option.none()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return Option.none()
|
|
105
|
+
}),
|
|
51
106
|
// This call is mostly here to for type narrowing
|
|
52
107
|
Stream.filter(Schema.is(WSMessage.PullRes)),
|
|
53
108
|
)
|