@livestore/sync-cf 0.2.0-dev.2 → 0.3.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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/cf-worker/durable-object.d.ts +21 -8
- package/dist/cf-worker/durable-object.d.ts.map +1 -1
- package/dist/cf-worker/durable-object.js +62 -48
- package/dist/cf-worker/durable-object.js.map +1 -1
- package/dist/common/ws-message-types.d.ts +113 -84
- package/dist/common/ws-message-types.d.ts.map +1 -1
- package/dist/common/ws-message-types.js +13 -9
- package/dist/common/ws-message-types.js.map +1 -1
- package/dist/sync-impl/ws-impl.d.ts +2 -1
- package/dist/sync-impl/ws-impl.d.ts.map +1 -1
- package/dist/sync-impl/ws-impl.js +66 -67
- package/dist/sync-impl/ws-impl.js.map +1 -1
- package/package.json +10 -3
- package/src/cf-worker/durable-object.ts +138 -109
- package/src/cf-worker/index.ts +1 -1
- package/src/common/ws-message-types.ts +18 -9
- package/src/sync-impl/ws-impl.ts +114 -107
- package/.netlify/state.json +0 -3
- package/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/8ee300993f9a2c909aede59646369fa0cc28d25304b9b9af94f54d4543ad60f4.sqlite +0 -0
- package/.wrangler/state/v3/do/websocket-server-WebSocketServer/2c3676d859102e448b271e1b4336b1e40778992b14728e2a2bd642c0f225e9c5.sqlite +0 -0
- package/.wrangler/state/v3/do/websocket-server-WebSocketServer/6eb1111cecdc6ae7e3a38c3d7db0f3ec7de5b318e682cdcf0ff0dc881cbbcee7.sqlite +0 -0
- package/.wrangler/state/v3/do/websocket-server-WebSocketServer/c4aa0e61dcd784604bee52282348e0538ab69ec76e1652322bbccd539d9ff3ee.sqlite +0 -0
- package/.wrangler/state/v3/do/websocket-server-WebSocketServer/d87433156774c933da83e962eba16107cf02ade2f133d7205963558fe47db3ff.sqlite +0 -0
- package/.wrangler/tmp/dev-33iU4b/index.js +0 -42167
- package/.wrangler/tmp/dev-33iU4b/index.js.map +0 -8
- package/.wrangler/tmp/dev-9rcIR8/index.js +0 -18887
- package/.wrangler/tmp/dev-9rcIR8/index.js.map +0 -8
- package/.wrangler/tmp/dev-rI63Kk/index.js +0 -42165
- package/.wrangler/tmp/dev-rI63Kk/index.js.map +0 -8
- package/.wrangler/tmp/dev-txPodK/index.js +0 -42118
- package/.wrangler/tmp/dev-txPodK/index.js.map +0 -8
- package/tsconfig.json +0 -12
- package/wrangler.toml +0 -21
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { makeColumnSpec
|
|
2
|
-
import { DbSchema, type MutationEvent
|
|
1
|
+
import { makeColumnSpec } from '@livestore/common'
|
|
2
|
+
import { DbSchema, EventId, type MutationEvent } from '@livestore/common/schema'
|
|
3
3
|
import { shouldNeverHappen } from '@livestore/utils'
|
|
4
|
-
import { Effect, Schema } from '@livestore/utils/effect'
|
|
4
|
+
import { Effect, Logger, LogLevel, Option, Schema } from '@livestore/utils/effect'
|
|
5
5
|
import { DurableObject } from 'cloudflare:workers'
|
|
6
6
|
|
|
7
7
|
import { WSMessage } from '../common/index.js'
|
|
8
|
+
import type { SyncMetadata } from '../common/ws-message-types.js'
|
|
8
9
|
|
|
9
10
|
export interface Env {
|
|
10
11
|
WEBSOCKET_SERVER: DurableObjectNamespace<WebSocketServer>
|
|
@@ -18,11 +19,14 @@ const encodeOutgoingMessage = Schema.encodeSync(Schema.parseJson(WSMessage.Backe
|
|
|
18
19
|
const encodeIncomingMessage = Schema.encodeSync(Schema.parseJson(WSMessage.ClientToBackendMessage))
|
|
19
20
|
const decodeIncomingMessage = Schema.decodeUnknownEither(Schema.parseJson(WSMessage.ClientToBackendMessage))
|
|
20
21
|
|
|
22
|
+
// NOTE actual table name is determined at runtime by `WebSocketServer.dbName`
|
|
21
23
|
export const mutationLogTable = DbSchema.table('__unused', {
|
|
22
24
|
idGlobal: DbSchema.integer({ primaryKey: true }),
|
|
23
25
|
parentIdGlobal: DbSchema.integer({}),
|
|
24
26
|
mutation: DbSchema.text({}),
|
|
25
27
|
args: DbSchema.text({ schema: Schema.parseJson(Schema.Any) }),
|
|
28
|
+
/** ISO date format */
|
|
29
|
+
createdAt: DbSchema.text({}),
|
|
26
30
|
})
|
|
27
31
|
|
|
28
32
|
// Durable Object
|
|
@@ -58,129 +62,142 @@ export class WebSocketServer extends DurableObject<Env> {
|
|
|
58
62
|
})
|
|
59
63
|
}).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
|
|
60
64
|
|
|
61
|
-
webSocketMessage =
|
|
62
|
-
|
|
65
|
+
webSocketMessage = (ws: WebSocketClient, message: ArrayBuffer | string) =>
|
|
66
|
+
Effect.gen(this, function* () {
|
|
67
|
+
const decodedMessageRes = decodeIncomingMessage(message)
|
|
63
68
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
if (decodedMessageRes._tag === 'Left') {
|
|
70
|
+
console.error('Invalid message received', decodedMessageRes.left)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
68
73
|
|
|
69
|
-
|
|
70
|
-
|
|
74
|
+
const decodedMessage = decodedMessageRes.right
|
|
75
|
+
const requestId = decodedMessage.requestId
|
|
71
76
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
try {
|
|
78
|
+
switch (decodedMessage._tag) {
|
|
79
|
+
case 'WSMessage.PullReq': {
|
|
80
|
+
const cursor = decodedMessage.cursor
|
|
81
|
+
const CHUNK_SIZE = 100
|
|
77
82
|
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
// TODO use streaming
|
|
84
|
+
const remainingEvents = [...(yield* Effect.promise(() => this.storage.getEvents(cursor)))]
|
|
80
85
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const encodedEvents = Schema.encodeSync(Schema.Array(mutationEventSchemaAny))(events)
|
|
85
|
-
const hasMore = remainingEvents.length > 0
|
|
86
|
+
// NOTE we want to make sure the WS server responds at least once with `InitRes` even if `events` is empty
|
|
87
|
+
while (true) {
|
|
88
|
+
const events = remainingEvents.splice(0, CHUNK_SIZE)
|
|
86
89
|
|
|
87
|
-
|
|
90
|
+
ws.send(
|
|
91
|
+
encodeOutgoingMessage(WSMessage.PullRes.make({ events, remaining: remainingEvents.length, requestId })),
|
|
92
|
+
)
|
|
88
93
|
|
|
89
|
-
|
|
90
|
-
|
|
94
|
+
if (remainingEvents.length === 0) {
|
|
95
|
+
break
|
|
96
|
+
}
|
|
91
97
|
}
|
|
98
|
+
|
|
99
|
+
break
|
|
92
100
|
}
|
|
101
|
+
case 'WSMessage.PushReq': {
|
|
102
|
+
// TODO check whether we could use the Durable Object storage for this to speed up the lookup
|
|
103
|
+
const latestEvent = yield* Effect.promise(() => this.storage.getLatestEvent())
|
|
104
|
+
const expectedParentId = latestEvent?.id ?? EventId.ROOT
|
|
105
|
+
|
|
106
|
+
let i = 0
|
|
107
|
+
for (const mutationEventEncoded of decodedMessage.batch) {
|
|
108
|
+
if (mutationEventEncoded.parentId.global !== expectedParentId.global + i) {
|
|
109
|
+
const err = WSMessage.Error.make({
|
|
110
|
+
message: `Invalid parent id. Received ${mutationEventEncoded.parentId.global} but expected ${expectedParentId.global}`,
|
|
111
|
+
requestId,
|
|
112
|
+
})
|
|
93
113
|
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
case 'WSMessage.PushReq': {
|
|
97
|
-
// TODO check whether we could use the Durable Object storage for this to speed up the lookup
|
|
98
|
-
const latestEvent = await this.storage.getLatestEvent()
|
|
99
|
-
const expectedParentId = latestEvent?.id ?? ROOT_ID
|
|
114
|
+
yield* Effect.fail(err).pipe(Effect.ignoreLogged)
|
|
100
115
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
WSMessage.Error.make({
|
|
105
|
-
message: `Invalid parent id. Received ${decodedMessage.mutationEventEncoded.parentId.global} but expected ${expectedParentId.global}`,
|
|
106
|
-
requestId,
|
|
107
|
-
}),
|
|
108
|
-
),
|
|
109
|
-
)
|
|
110
|
-
return
|
|
111
|
-
}
|
|
116
|
+
ws.send(encodeOutgoingMessage(err))
|
|
117
|
+
return
|
|
118
|
+
}
|
|
112
119
|
|
|
113
|
-
|
|
120
|
+
// TODO handle clientId unique conflict
|
|
114
121
|
|
|
115
|
-
|
|
116
|
-
const storePromise = decodedMessage.persisted
|
|
117
|
-
? this.storage.appendEvent(decodedMessage.mutationEventEncoded)
|
|
118
|
-
: Promise.resolve()
|
|
122
|
+
const createdAt = new Date().toISOString()
|
|
119
123
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
WSMessage.PushAck.make({ mutationId: decodedMessage.mutationEventEncoded.id.global, requestId }),
|
|
123
|
-
),
|
|
124
|
-
)
|
|
124
|
+
// NOTE we're currently not blocking on this to allow broadcasting right away
|
|
125
|
+
const storePromise = this.storage.appendEvent(mutationEventEncoded, createdAt)
|
|
125
126
|
|
|
126
|
-
|
|
127
|
+
ws.send(
|
|
128
|
+
encodeOutgoingMessage(
|
|
129
|
+
WSMessage.PushAck.make({ mutationId: mutationEventEncoded.id.global, requestId }),
|
|
130
|
+
),
|
|
131
|
+
)
|
|
127
132
|
|
|
128
|
-
|
|
133
|
+
// console.debug(`Broadcasting mutation event to ${this.subscribedWebSockets.size} clients`)
|
|
129
134
|
|
|
130
|
-
|
|
131
|
-
const broadcastMessage = encodeOutgoingMessage(
|
|
132
|
-
WSMessage.PushBroadcast.make({
|
|
133
|
-
mutationEventEncoded: decodedMessage.mutationEventEncoded,
|
|
134
|
-
persisted: decodedMessage.persisted,
|
|
135
|
-
}),
|
|
136
|
-
)
|
|
135
|
+
const connectedClients = this.ctx.getWebSockets()
|
|
137
136
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
137
|
+
if (connectedClients.length > 0) {
|
|
138
|
+
const broadcastMessage = encodeOutgoingMessage(
|
|
139
|
+
// TODO refactor to batch api
|
|
140
|
+
WSMessage.PushBroadcast.make({
|
|
141
|
+
mutationEventEncoded,
|
|
142
|
+
metadata: Option.some({ createdAt }),
|
|
143
|
+
}),
|
|
144
|
+
)
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
for (const conn of connectedClients) {
|
|
147
|
+
console.log('Broadcasting to client', conn === ws ? 'self' : 'other')
|
|
148
|
+
// if (conn !== ws) {
|
|
149
|
+
conn.send(broadcastMessage)
|
|
150
|
+
// }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
147
153
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
+
yield* Effect.promise(() => storePromise)
|
|
155
|
+
|
|
156
|
+
i++
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
break
|
|
154
160
|
}
|
|
161
|
+
case 'WSMessage.AdminResetRoomReq': {
|
|
162
|
+
if (decodedMessage.adminSecret !== this.env.ADMIN_SECRET) {
|
|
163
|
+
ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: 'Invalid admin secret', requestId })))
|
|
164
|
+
return
|
|
165
|
+
}
|
|
155
166
|
|
|
156
|
-
|
|
157
|
-
|
|
167
|
+
yield* Effect.promise(() => this.storage.resetRoom())
|
|
168
|
+
ws.send(encodeOutgoingMessage(WSMessage.AdminResetRoomRes.make({ requestId })))
|
|
158
169
|
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
case 'WSMessage.AdminInfoReq': {
|
|
162
|
-
if (decodedMessage.adminSecret !== this.env.ADMIN_SECRET) {
|
|
163
|
-
ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: 'Invalid admin secret', requestId })))
|
|
164
|
-
return
|
|
170
|
+
break
|
|
165
171
|
}
|
|
172
|
+
case 'WSMessage.AdminInfoReq': {
|
|
173
|
+
if (decodedMessage.adminSecret !== this.env.ADMIN_SECRET) {
|
|
174
|
+
ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: 'Invalid admin secret', requestId })))
|
|
175
|
+
return
|
|
176
|
+
}
|
|
166
177
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
178
|
+
ws.send(
|
|
179
|
+
encodeOutgoingMessage(
|
|
180
|
+
WSMessage.AdminInfoRes.make({ requestId, info: { durableObjectId: this.ctx.id.toString() } }),
|
|
181
|
+
),
|
|
182
|
+
)
|
|
172
183
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
default: {
|
|
187
|
+
console.error('unsupported message', decodedMessage)
|
|
188
|
+
return shouldNeverHappen()
|
|
189
|
+
}
|
|
178
190
|
}
|
|
191
|
+
} catch (error: any) {
|
|
192
|
+
ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: error.message, requestId })))
|
|
179
193
|
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
194
|
+
}).pipe(
|
|
195
|
+
Effect.withSpan('@livestore/sync-cf:durable-object:webSocketMessage'),
|
|
196
|
+
Effect.tapCauseLogPretty,
|
|
197
|
+
Logger.withMinimumLogLevel(LogLevel.Debug),
|
|
198
|
+
Effect.provide(Logger.pretty),
|
|
199
|
+
Effect.runPromise,
|
|
200
|
+
)
|
|
184
201
|
|
|
185
202
|
webSocketClose = async (ws: WebSocketClient, code: number, _reason: string, _wasClean: boolean) => {
|
|
186
203
|
// If the client closes the connection, the runtime will invoke the webSocketClose() handler.
|
|
@@ -196,31 +213,43 @@ const makeStorage = (ctx: DurableObjectState, env: Env, dbName: string) => {
|
|
|
196
213
|
}
|
|
197
214
|
const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results).map((e) => ({
|
|
198
215
|
...e,
|
|
216
|
+
// TODO remove local ids
|
|
199
217
|
id: { global: e.idGlobal, local: 0 },
|
|
200
218
|
parentId: { global: e.parentIdGlobal, local: 0 },
|
|
201
219
|
}))
|
|
202
220
|
return events[0]
|
|
203
221
|
}
|
|
204
222
|
|
|
205
|
-
const getEvents = async (
|
|
206
|
-
|
|
223
|
+
const getEvents = async (
|
|
224
|
+
cursor: number | undefined,
|
|
225
|
+
): Promise<
|
|
226
|
+
ReadonlyArray<{ mutationEventEncoded: MutationEvent.AnyEncoded; metadata: Option.Option<SyncMetadata> }>
|
|
227
|
+
> => {
|
|
228
|
+
const whereClause = cursor === undefined ? '' : `WHERE idGlobal > ${cursor}`
|
|
229
|
+
const sql = `SELECT * FROM ${dbName} ${whereClause} ORDER BY idGlobal ASC`
|
|
207
230
|
// TODO handle case where `cursor` was not found
|
|
208
|
-
const rawEvents = await env.DB.prepare(
|
|
231
|
+
const rawEvents = await env.DB.prepare(sql).all()
|
|
209
232
|
if (rawEvents.error) {
|
|
210
233
|
throw new Error(rawEvents.error)
|
|
211
234
|
}
|
|
212
|
-
const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results).map(
|
|
213
|
-
...e
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
235
|
+
const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results).map(
|
|
236
|
+
({ createdAt, ...e }) => ({
|
|
237
|
+
mutationEventEncoded: {
|
|
238
|
+
...e,
|
|
239
|
+
// TODO remove local ids
|
|
240
|
+
id: { global: e.idGlobal, local: 0 },
|
|
241
|
+
parentId: { global: e.parentIdGlobal, local: 0 },
|
|
242
|
+
},
|
|
243
|
+
metadata: Option.some({ createdAt }),
|
|
244
|
+
}),
|
|
245
|
+
)
|
|
217
246
|
return events
|
|
218
247
|
}
|
|
219
248
|
|
|
220
|
-
const appendEvent = async (event: MutationEvent.Any) => {
|
|
221
|
-
const sql = `INSERT INTO ${dbName} (idGlobal, parentIdGlobal, args, mutation) VALUES (?, ?, ?, ?)`
|
|
249
|
+
const appendEvent = async (event: MutationEvent.Any, createdAt: string) => {
|
|
250
|
+
const sql = `INSERT INTO ${dbName} (idGlobal, parentIdGlobal, args, mutation, createdAt) VALUES (?, ?, ?, ?, ?)`
|
|
222
251
|
await env.DB.prepare(sql)
|
|
223
|
-
.bind(event.id.global, event.parentId.global, JSON.stringify(event.args), event.mutation)
|
|
252
|
+
.bind(event.id.global, event.parentId.global, JSON.stringify(event.args), event.mutation, createdAt)
|
|
224
253
|
.run()
|
|
225
254
|
}
|
|
226
255
|
|
package/src/cf-worker/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/// <reference no-default-lib="true"/>
|
|
2
2
|
/// <reference lib="esnext" />
|
|
3
3
|
|
|
4
|
-
// import {
|
|
4
|
+
// import { EncodedAny } from '@livestore/common/schema'
|
|
5
5
|
// import { Effect, HttpServer, Schema } from '@livestore/utils/effect'
|
|
6
6
|
|
|
7
7
|
import type { Env } from './durable-object.js'
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { MutationEvent } from '@livestore/common/schema'
|
|
2
2
|
import { Schema } from '@livestore/utils/effect'
|
|
3
3
|
|
|
4
4
|
export const PullReq = Schema.TaggedStruct('WSMessage.PullReq', {
|
|
@@ -9,27 +9,36 @@ export const PullReq = Schema.TaggedStruct('WSMessage.PullReq', {
|
|
|
9
9
|
|
|
10
10
|
export type PullReq = typeof PullReq.Type
|
|
11
11
|
|
|
12
|
+
export const SyncMetadata = Schema.Struct({
|
|
13
|
+
/** ISO date format */
|
|
14
|
+
createdAt: Schema.String,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export type SyncMetadata = typeof SyncMetadata.Type
|
|
18
|
+
|
|
12
19
|
export const PullRes = Schema.TaggedStruct('WSMessage.PullRes', {
|
|
13
20
|
requestId: Schema.String,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
events: Schema.Array(
|
|
22
|
+
Schema.Struct({
|
|
23
|
+
mutationEventEncoded: MutationEvent.EncodedAny,
|
|
24
|
+
metadata: Schema.Option(SyncMetadata),
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
remaining: Schema.Number,
|
|
18
28
|
})
|
|
19
29
|
|
|
20
30
|
export type PullRes = typeof PullRes.Type
|
|
21
31
|
|
|
22
32
|
export const PushBroadcast = Schema.TaggedStruct('WSMessage.PushBroadcast', {
|
|
23
|
-
mutationEventEncoded:
|
|
24
|
-
|
|
33
|
+
mutationEventEncoded: MutationEvent.EncodedAny,
|
|
34
|
+
metadata: Schema.Option(SyncMetadata),
|
|
25
35
|
})
|
|
26
36
|
|
|
27
37
|
export type PushBroadcast = typeof PushBroadcast.Type
|
|
28
38
|
|
|
29
39
|
export const PushReq = Schema.TaggedStruct('WSMessage.PushReq', {
|
|
30
40
|
requestId: Schema.String,
|
|
31
|
-
|
|
32
|
-
persisted: Schema.Boolean,
|
|
41
|
+
batch: Schema.Array(MutationEvent.EncodedAny),
|
|
33
42
|
})
|
|
34
43
|
|
|
35
44
|
export type PushReq = typeof PushReq.Type
|