@livestore/sync-cf 0.3.0-dev.11 → 0.3.0-dev.3
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 +34 -27
- package/dist/cf-worker/durable-object.d.ts.map +1 -1
- package/dist/cf-worker/durable-object.js +129 -150
- package/dist/cf-worker/durable-object.js.map +1 -1
- package/dist/cf-worker/index.d.ts +6 -1
- package/dist/cf-worker/index.d.ts.map +1 -1
- package/dist/cf-worker/index.js +64 -30
- package/dist/cf-worker/index.js.map +1 -1
- package/dist/common/ws-message-types.d.ts +207 -54
- package/dist/common/ws-message-types.d.ts.map +1 -1
- package/dist/common/ws-message-types.js +3 -3
- package/dist/common/ws-message-types.js.map +1 -1
- package/dist/sync-impl/ws-impl.d.ts +12 -3
- package/dist/sync-impl/ws-impl.d.ts.map +1 -1
- package/dist/sync-impl/ws-impl.js +4 -5
- package/dist/sync-impl/ws-impl.js.map +1 -1
- package/package.json +13 -15
- package/src/cf-worker/durable-object.ts +161 -215
- package/src/cf-worker/index.ts +84 -0
- package/src/common/ws-message-types.ts +3 -3
- package/src/sync-impl/ws-impl.ts +16 -8
- package/dist/cf-worker/make-worker.d.ts +0 -6
- package/dist/cf-worker/make-worker.d.ts.map +0 -1
- package/dist/cf-worker/make-worker.js +0 -31
- package/dist/cf-worker/make-worker.js.map +0 -1
- package/dist/cf-worker/mod.d.ts +0 -3
- package/dist/cf-worker/mod.d.ts.map +0 -1
- package/dist/cf-worker/mod.js +0 -3
- package/dist/cf-worker/mod.js.map +0 -1
- package/dist/cf-worker/types.d.ts +0 -2
- package/dist/cf-worker/types.d.ts.map +0 -1
- package/dist/cf-worker/types.js +0 -2
- package/dist/cf-worker/types.js.map +0 -1
- package/dist/cf-worker/worker.d.ts +0 -6
- package/dist/cf-worker/worker.d.ts.map +0 -1
- package/dist/cf-worker/worker.js +0 -29
- package/dist/cf-worker/worker.js.map +0 -1
- package/dist/common/mod.d.ts +0 -2
- package/dist/common/mod.d.ts.map +0 -1
- package/dist/common/mod.js +0 -2
- package/dist/common/mod.js.map +0 -1
- package/dist/sync-impl/mod.d.ts +0 -2
- package/dist/sync-impl/mod.d.ts.map +0 -1
- package/dist/sync-impl/mod.js +0 -2
- package/dist/sync-impl/mod.js.map +0 -1
- package/src/cf-worker/mod.ts +0 -2
- package/src/cf-worker/worker.ts +0 -39
- /package/src/common/{mod.ts → index.ts} +0 -0
- /package/src/sync-impl/{mod.ts → index.ts} +0 -0
|
@@ -4,11 +4,11 @@ import { shouldNeverHappen } from '@livestore/utils'
|
|
|
4
4
|
import { Effect, Logger, LogLevel, Option, Schema } from '@livestore/utils/effect'
|
|
5
5
|
import { DurableObject } from 'cloudflare:workers'
|
|
6
6
|
|
|
7
|
-
import { WSMessage } from '../common/
|
|
7
|
+
import { WSMessage } from '../common/index.js'
|
|
8
8
|
import type { SyncMetadata } from '../common/ws-message-types.js'
|
|
9
9
|
|
|
10
10
|
export interface Env {
|
|
11
|
-
WEBSOCKET_SERVER: DurableObjectNamespace
|
|
11
|
+
WEBSOCKET_SERVER: DurableObjectNamespace<WebSocketServer>
|
|
12
12
|
DB: D1Database
|
|
13
13
|
ADMIN_SECRET: string
|
|
14
14
|
}
|
|
@@ -21,277 +21,235 @@ const decodeIncomingMessage = Schema.decodeUnknownEither(Schema.parseJson(WSMess
|
|
|
21
21
|
|
|
22
22
|
// NOTE actual table name is determined at runtime by `WebSocketServer.dbName`
|
|
23
23
|
export const mutationLogTable = DbSchema.table('__unused', {
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
idGlobal: DbSchema.integer({ primaryKey: true }),
|
|
25
|
+
parentIdGlobal: DbSchema.integer({}),
|
|
26
26
|
mutation: DbSchema.text({}),
|
|
27
27
|
args: DbSchema.text({ schema: Schema.parseJson(Schema.Any) }),
|
|
28
|
-
/** ISO date format
|
|
28
|
+
/** ISO date format */
|
|
29
29
|
createdAt: DbSchema.text({}),
|
|
30
30
|
})
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Needs to be bumped when the storage format changes (e.g. mutationLogTable schema changes)
|
|
40
|
-
*
|
|
41
|
-
* Changing this version number will lead to a "soft reset".
|
|
42
|
-
*/
|
|
43
|
-
export const PERSISTENCE_FORMAT_VERSION = 2
|
|
44
|
-
|
|
45
|
-
export type MakeDurableObjectClassOptions = {
|
|
46
|
-
onPush?: (message: WSMessage.PushReq) => Effect.Effect<void> | Promise<void>
|
|
47
|
-
onPull?: (message: WSMessage.PullReq) => Effect.Effect<void> | Promise<void>
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export type MakeDurableObjectClass = (options?: MakeDurableObjectClassOptions) => {
|
|
51
|
-
new (ctx: DurableObjectState, env: Env): DurableObject<Env>
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
55
|
-
return class WebSocketServerBase extends DurableObject<Env> {
|
|
56
|
-
constructor(ctx: DurableObjectState, env: Env) {
|
|
57
|
-
super(ctx, env)
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
fetch = async (request: Request) =>
|
|
61
|
-
Effect.gen(this, function* () {
|
|
62
|
-
const storeId = getStoreId(request)
|
|
63
|
-
const storage = makeStorage(this.ctx, this.env, storeId)
|
|
64
|
-
|
|
65
|
-
const { 0: client, 1: server } = new WebSocketPair()
|
|
32
|
+
// Durable Object
|
|
33
|
+
export class WebSocketServer extends DurableObject<Env> {
|
|
34
|
+
dbName = `mutation_log_${this.ctx.id.toString()}`
|
|
35
|
+
storage = makeStorage(this.ctx, this.env, this.dbName)
|
|
66
36
|
|
|
67
|
-
|
|
68
|
-
|
|
37
|
+
constructor(ctx: DurableObjectState, env: Env) {
|
|
38
|
+
super(ctx, env)
|
|
39
|
+
}
|
|
69
40
|
|
|
70
|
-
|
|
41
|
+
fetch = async (_request: Request) =>
|
|
42
|
+
Effect.gen(this, function* () {
|
|
43
|
+
const { 0: client, 1: server } = new WebSocketPair()
|
|
71
44
|
|
|
72
|
-
|
|
45
|
+
// See https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server
|
|
73
46
|
|
|
74
|
-
|
|
75
|
-
new WebSocketRequestResponsePair(
|
|
76
|
-
encodeIncomingMessage(WSMessage.Ping.make({ requestId: 'ping' })),
|
|
77
|
-
encodeOutgoingMessage(WSMessage.Pong.make({ requestId: 'ping' })),
|
|
78
|
-
),
|
|
79
|
-
)
|
|
47
|
+
this.ctx.acceptWebSocket(server)
|
|
80
48
|
|
|
81
|
-
|
|
82
|
-
|
|
49
|
+
this.ctx.setWebSocketAutoResponse(
|
|
50
|
+
new WebSocketRequestResponsePair(
|
|
51
|
+
encodeIncomingMessage(WSMessage.Ping.make({ requestId: 'ping' })),
|
|
52
|
+
encodeOutgoingMessage(WSMessage.Pong.make({ requestId: 'ping' })),
|
|
53
|
+
),
|
|
54
|
+
)
|
|
83
55
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
webSocket: client,
|
|
87
|
-
})
|
|
88
|
-
}).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
|
|
56
|
+
const colSpec = makeColumnSpec(mutationLogTable.sqliteDef.ast)
|
|
57
|
+
this.env.DB.exec(`CREATE TABLE IF NOT EXISTS ${this.dbName} (${colSpec}) strict`)
|
|
89
58
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
59
|
+
return new Response(null, {
|
|
60
|
+
status: 101,
|
|
61
|
+
webSocket: client,
|
|
62
|
+
})
|
|
63
|
+
}).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
|
|
93
64
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
65
|
+
webSocketMessage = (ws: WebSocketClient, message: ArrayBuffer | string) =>
|
|
66
|
+
Effect.gen(this, function* () {
|
|
67
|
+
const decodedMessageRes = decodeIncomingMessage(message)
|
|
98
68
|
|
|
99
|
-
|
|
100
|
-
|
|
69
|
+
if (decodedMessageRes._tag === 'Left') {
|
|
70
|
+
console.error('Invalid message received', decodedMessageRes.left)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
101
73
|
|
|
102
|
-
|
|
103
|
-
|
|
74
|
+
const decodedMessage = decodedMessageRes.right
|
|
75
|
+
const requestId = decodedMessage.requestId
|
|
104
76
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
77
|
+
try {
|
|
78
|
+
switch (decodedMessage._tag) {
|
|
79
|
+
case 'WSMessage.PullReq': {
|
|
80
|
+
const cursor = decodedMessage.cursor
|
|
81
|
+
const CHUNK_SIZE = 100
|
|
111
82
|
|
|
112
|
-
|
|
113
|
-
|
|
83
|
+
// TODO use streaming
|
|
84
|
+
const remainingEvents = [...(yield* Effect.promise(() => this.storage.getEvents(cursor)))]
|
|
114
85
|
|
|
115
|
-
|
|
116
|
-
|
|
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)
|
|
117
89
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
ws.send(
|
|
123
|
-
encodeOutgoingMessage(
|
|
124
|
-
WSMessage.PullRes.make({ events, remaining: remainingEvents.length, requestId }),
|
|
125
|
-
),
|
|
126
|
-
)
|
|
90
|
+
ws.send(
|
|
91
|
+
encodeOutgoingMessage(WSMessage.PullRes.make({ events, remaining: remainingEvents.length, requestId })),
|
|
92
|
+
)
|
|
127
93
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
94
|
+
if (remainingEvents.length === 0) {
|
|
95
|
+
break
|
|
131
96
|
}
|
|
132
|
-
|
|
133
|
-
break
|
|
134
97
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
98
|
+
|
|
99
|
+
break
|
|
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
|
+
})
|
|
113
|
+
|
|
114
|
+
yield* Effect.fail(err).pipe(Effect.ignoreLogged)
|
|
115
|
+
|
|
116
|
+
ws.send(encodeOutgoingMessage(err))
|
|
117
|
+
return
|
|
138
118
|
}
|
|
139
119
|
|
|
140
|
-
// TODO
|
|
141
|
-
const latestEvent = yield* Effect.promise(() => storage.getLatestEvent())
|
|
142
|
-
const expectedParentId = latestEvent?.id ?? EventId.ROOT.global
|
|
120
|
+
// TODO handle clientId unique conflict
|
|
143
121
|
|
|
144
|
-
|
|
145
|
-
for (const mutationEventEncoded of decodedMessage.batch) {
|
|
146
|
-
if (mutationEventEncoded.parentId !== expectedParentId + i) {
|
|
147
|
-
const err = WSMessage.Error.make({
|
|
148
|
-
message: `Invalid parent id. Received ${mutationEventEncoded.parentId} but expected ${expectedParentId}`,
|
|
149
|
-
requestId,
|
|
150
|
-
})
|
|
122
|
+
const createdAt = new Date().toISOString()
|
|
151
123
|
|
|
152
|
-
|
|
124
|
+
// NOTE we're currently not blocking on this to allow broadcasting right away
|
|
125
|
+
const storePromise = this.storage.appendEvent(mutationEventEncoded, createdAt)
|
|
153
126
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
127
|
+
ws.send(
|
|
128
|
+
encodeOutgoingMessage(
|
|
129
|
+
WSMessage.PushAck.make({ mutationId: mutationEventEncoded.id.global, requestId }),
|
|
130
|
+
),
|
|
131
|
+
)
|
|
159
132
|
|
|
160
|
-
|
|
133
|
+
// console.debug(`Broadcasting mutation event to ${this.subscribedWebSockets.size} clients`)
|
|
161
134
|
|
|
162
|
-
|
|
163
|
-
const storePromise = storage.appendEvent(mutationEventEncoded, createdAt)
|
|
135
|
+
const connectedClients = this.ctx.getWebSockets()
|
|
164
136
|
|
|
165
|
-
|
|
166
|
-
|
|
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
|
+
}),
|
|
167
144
|
)
|
|
168
145
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const broadcastMessage = encodeOutgoingMessage(
|
|
175
|
-
// TODO refactor to batch api
|
|
176
|
-
WSMessage.PushBroadcast.make({
|
|
177
|
-
mutationEventEncoded,
|
|
178
|
-
metadata: Option.some({ createdAt }),
|
|
179
|
-
}),
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
for (const conn of connectedClients) {
|
|
183
|
-
console.log('Broadcasting to client', conn === ws ? 'self' : 'other')
|
|
184
|
-
// if (conn !== ws) {
|
|
185
|
-
conn.send(broadcastMessage)
|
|
186
|
-
// }
|
|
187
|
-
}
|
|
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
|
+
// }
|
|
188
151
|
}
|
|
152
|
+
}
|
|
189
153
|
|
|
190
|
-
|
|
154
|
+
yield* Effect.promise(() => storePromise)
|
|
191
155
|
|
|
192
|
-
|
|
193
|
-
|
|
156
|
+
i++
|
|
157
|
+
}
|
|
194
158
|
|
|
195
|
-
|
|
159
|
+
break
|
|
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
|
|
196
165
|
}
|
|
197
|
-
case 'WSMessage.AdminResetRoomReq': {
|
|
198
|
-
if (decodedMessage.adminSecret !== this.env.ADMIN_SECRET) {
|
|
199
|
-
ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: 'Invalid admin secret', requestId })))
|
|
200
|
-
return
|
|
201
|
-
}
|
|
202
166
|
|
|
203
|
-
|
|
204
|
-
|
|
167
|
+
yield* Effect.promise(() => this.storage.resetRoom())
|
|
168
|
+
ws.send(encodeOutgoingMessage(WSMessage.AdminResetRoomRes.make({ requestId })))
|
|
205
169
|
|
|
206
|
-
|
|
170
|
+
break
|
|
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
|
|
207
176
|
}
|
|
208
|
-
case 'WSMessage.AdminInfoReq': {
|
|
209
|
-
if (decodedMessage.adminSecret !== this.env.ADMIN_SECRET) {
|
|
210
|
-
ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: 'Invalid admin secret', requestId })))
|
|
211
|
-
return
|
|
212
|
-
}
|
|
213
177
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
178
|
+
ws.send(
|
|
179
|
+
encodeOutgoingMessage(
|
|
180
|
+
WSMessage.AdminInfoRes.make({ requestId, info: { durableObjectId: this.ctx.id.toString() } }),
|
|
181
|
+
),
|
|
182
|
+
)
|
|
219
183
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
default: {
|
|
187
|
+
console.error('unsupported message', decodedMessage)
|
|
188
|
+
return shouldNeverHappen()
|
|
226
189
|
}
|
|
227
|
-
} catch (error: any) {
|
|
228
|
-
ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: error.message, requestId })))
|
|
229
190
|
}
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
)
|
|
191
|
+
} catch (error: any) {
|
|
192
|
+
ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: error.message, requestId })))
|
|
193
|
+
}
|
|
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
|
+
)
|
|
237
201
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
202
|
+
webSocketClose = async (ws: WebSocketClient, code: number, _reason: string, _wasClean: boolean) => {
|
|
203
|
+
// If the client closes the connection, the runtime will invoke the webSocketClose() handler.
|
|
204
|
+
ws.close(code, 'Durable Object is closing WebSocket')
|
|
242
205
|
}
|
|
243
206
|
}
|
|
244
207
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
getEvents: (
|
|
249
|
-
cursor: number | undefined,
|
|
250
|
-
) => Promise<
|
|
251
|
-
ReadonlyArray<{ mutationEventEncoded: MutationEvent.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> }>
|
|
252
|
-
>
|
|
253
|
-
appendEvent: (event: MutationEvent.AnyEncodedGlobal, createdAt: string) => Promise<void>
|
|
254
|
-
resetRoom: () => Promise<void>
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const makeStorage = (ctx: DurableObjectState, env: Env, storeId: string): SyncStorage => {
|
|
258
|
-
const dbName = `mutation_log_${PERSISTENCE_FORMAT_VERSION}_${toValidTableName(storeId)}`
|
|
259
|
-
|
|
260
|
-
const getLatestEvent = async (): Promise<MutationEvent.AnyEncodedGlobal | undefined> => {
|
|
261
|
-
const rawEvents = await env.DB.prepare(`SELECT * FROM ${dbName} ORDER BY id DESC LIMIT 1`).all()
|
|
208
|
+
const makeStorage = (ctx: DurableObjectState, env: Env, dbName: string) => {
|
|
209
|
+
const getLatestEvent = async (): Promise<MutationEvent.Any | undefined> => {
|
|
210
|
+
const rawEvents = await env.DB.prepare(`SELECT * FROM ${dbName} ORDER BY idGlobal DESC LIMIT 1`).all()
|
|
262
211
|
if (rawEvents.error) {
|
|
263
212
|
throw new Error(rawEvents.error)
|
|
264
213
|
}
|
|
265
|
-
const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results)
|
|
266
|
-
|
|
214
|
+
const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results).map((e) => ({
|
|
215
|
+
...e,
|
|
216
|
+
// TODO remove local ids
|
|
217
|
+
id: { global: e.idGlobal, local: 0 },
|
|
218
|
+
parentId: { global: e.parentIdGlobal, local: 0 },
|
|
219
|
+
}))
|
|
267
220
|
return events[0]
|
|
268
221
|
}
|
|
269
222
|
|
|
270
223
|
const getEvents = async (
|
|
271
224
|
cursor: number | undefined,
|
|
272
225
|
): Promise<
|
|
273
|
-
ReadonlyArray<{ mutationEventEncoded: MutationEvent.
|
|
226
|
+
ReadonlyArray<{ mutationEventEncoded: MutationEvent.AnyEncoded; metadata: Option.Option<SyncMetadata> }>
|
|
274
227
|
> => {
|
|
275
|
-
const whereClause = cursor === undefined ? '' : `WHERE
|
|
276
|
-
const sql = `SELECT * FROM ${dbName} ${whereClause} ORDER BY
|
|
228
|
+
const whereClause = cursor === undefined ? '' : `WHERE idGlobal > ${cursor}`
|
|
229
|
+
const sql = `SELECT * FROM ${dbName} ${whereClause} ORDER BY idGlobal ASC`
|
|
277
230
|
// TODO handle case where `cursor` was not found
|
|
278
231
|
const rawEvents = await env.DB.prepare(sql).all()
|
|
279
232
|
if (rawEvents.error) {
|
|
280
233
|
throw new Error(rawEvents.error)
|
|
281
234
|
}
|
|
282
235
|
const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results).map(
|
|
283
|
-
({ createdAt, ...
|
|
284
|
-
mutationEventEncoded
|
|
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
|
+
},
|
|
285
243
|
metadata: Option.some({ createdAt }),
|
|
286
244
|
}),
|
|
287
245
|
)
|
|
288
246
|
return events
|
|
289
247
|
}
|
|
290
248
|
|
|
291
|
-
const appendEvent = async (event: MutationEvent.
|
|
292
|
-
const sql = `INSERT INTO ${dbName} (
|
|
249
|
+
const appendEvent = async (event: MutationEvent.Any, createdAt: string) => {
|
|
250
|
+
const sql = `INSERT INTO ${dbName} (idGlobal, parentIdGlobal, args, mutation, createdAt) VALUES (?, ?, ?, ?, ?)`
|
|
293
251
|
await env.DB.prepare(sql)
|
|
294
|
-
.bind(event.id, event.parentId, JSON.stringify(event.args), event.mutation, createdAt)
|
|
252
|
+
.bind(event.id.global, event.parentId.global, JSON.stringify(event.args), event.mutation, createdAt)
|
|
295
253
|
.run()
|
|
296
254
|
}
|
|
297
255
|
|
|
@@ -299,17 +257,5 @@ const makeStorage = (ctx: DurableObjectState, env: Env, storeId: string): SyncSt
|
|
|
299
257
|
await ctx.storage.deleteAll()
|
|
300
258
|
}
|
|
301
259
|
|
|
302
|
-
return {
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const getStoreId = (request: Request) => {
|
|
306
|
-
const url = new URL(request.url)
|
|
307
|
-
const searchParams = url.searchParams
|
|
308
|
-
const storeId = searchParams.get('storeId')
|
|
309
|
-
if (storeId === null) {
|
|
310
|
-
throw new Error('storeId search param is required')
|
|
311
|
-
}
|
|
312
|
-
return storeId
|
|
260
|
+
return { getLatestEvent, getEvents, appendEvent, resetRoom }
|
|
313
261
|
}
|
|
314
|
-
|
|
315
|
-
const toValidTableName = (str: string) => str.replaceAll(/[^a-zA-Z0-9]/g, '_')
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/// <reference no-default-lib="true"/>
|
|
2
|
+
/// <reference lib="esnext" />
|
|
3
|
+
|
|
4
|
+
// import { EncodedAny } from '@livestore/common/schema'
|
|
5
|
+
// import { Effect, HttpServer, Schema } from '@livestore/utils/effect'
|
|
6
|
+
|
|
7
|
+
import type { Env } from './durable-object.js'
|
|
8
|
+
|
|
9
|
+
export * from './durable-object.js'
|
|
10
|
+
|
|
11
|
+
// const handleRequest = (request: Request, env: Env) =>
|
|
12
|
+
// HttpServer.router.empty.pipe(
|
|
13
|
+
// HttpServer.router.get(
|
|
14
|
+
// '/websocket',
|
|
15
|
+
// Effect.gen(function* () {
|
|
16
|
+
// // This example will refer to the same Durable Object instance,
|
|
17
|
+
// // since the name "foo" is hardcoded.
|
|
18
|
+
// const id = env.WEBSOCKET_SERVER.idFromName('foo')
|
|
19
|
+
// const durableObject = env.WEBSOCKET_SERVER.get(id)
|
|
20
|
+
|
|
21
|
+
// HttpServer.
|
|
22
|
+
|
|
23
|
+
// // Expect to receive a WebSocket Upgrade request.
|
|
24
|
+
// // If there is one, accept the request and return a WebSocket Response.
|
|
25
|
+
// const headerRes = yield* HttpServer.request
|
|
26
|
+
// .schemaHeaders(
|
|
27
|
+
// Schema.Struct({
|
|
28
|
+
// Upgrade: Schema.Literal('websocket'),
|
|
29
|
+
// }),
|
|
30
|
+
// )
|
|
31
|
+
// .pipe(Effect.either)
|
|
32
|
+
|
|
33
|
+
// if (headerRes._tag === 'Left') {
|
|
34
|
+
// // return new Response('Durable Object expected Upgrade: websocket', { status: 426 })
|
|
35
|
+
// return yield* HttpServer.response.text('Durable Object expected Upgrade: websocket', { status: 426 })
|
|
36
|
+
// }
|
|
37
|
+
|
|
38
|
+
// HttpServer.response.empty
|
|
39
|
+
|
|
40
|
+
// return yield* Effect.promise(() => durableObject.fetch(request))
|
|
41
|
+
// }),
|
|
42
|
+
// ),
|
|
43
|
+
// HttpServer.router.catchAll((e) => {
|
|
44
|
+
// console.log(e)
|
|
45
|
+
// return HttpServer.response.empty({ status: 400 })
|
|
46
|
+
// }),
|
|
47
|
+
// (_) => HttpServer.app.toWebHandler(_)(request),
|
|
48
|
+
// // request
|
|
49
|
+
// )
|
|
50
|
+
|
|
51
|
+
// Worker
|
|
52
|
+
export default {
|
|
53
|
+
fetch: async (request: Request, env: Env, _ctx: ExecutionContext): Promise<Response> => {
|
|
54
|
+
const url = new URL(request.url)
|
|
55
|
+
const searchParams = url.searchParams
|
|
56
|
+
const roomId = searchParams.get('room')
|
|
57
|
+
|
|
58
|
+
if (roomId === null) {
|
|
59
|
+
return new Response('Room ID is required', { status: 400 })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// This example will refer to the same Durable Object instance,
|
|
63
|
+
// since the name "foo" is hardcoded.
|
|
64
|
+
const id = env.WEBSOCKET_SERVER.idFromName(roomId)
|
|
65
|
+
const durableObject = env.WEBSOCKET_SERVER.get(id)
|
|
66
|
+
|
|
67
|
+
if (url.pathname.endsWith('/websocket')) {
|
|
68
|
+
const upgradeHeader = request.headers.get('Upgrade')
|
|
69
|
+
if (!upgradeHeader || upgradeHeader !== 'websocket') {
|
|
70
|
+
return new Response('Durable Object expected Upgrade: websocket', { status: 426 })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return durableObject.fetch(request)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return new Response(null, {
|
|
77
|
+
status: 400,
|
|
78
|
+
statusText: 'Bad Request',
|
|
79
|
+
headers: {
|
|
80
|
+
'Content-Type': 'text/plain',
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
},
|
|
84
|
+
}
|
|
@@ -20,7 +20,7 @@ export const PullRes = Schema.TaggedStruct('WSMessage.PullRes', {
|
|
|
20
20
|
requestId: Schema.String,
|
|
21
21
|
events: Schema.Array(
|
|
22
22
|
Schema.Struct({
|
|
23
|
-
mutationEventEncoded: MutationEvent.
|
|
23
|
+
mutationEventEncoded: MutationEvent.EncodedAny,
|
|
24
24
|
metadata: Schema.Option(SyncMetadata),
|
|
25
25
|
}),
|
|
26
26
|
),
|
|
@@ -30,7 +30,7 @@ export const PullRes = Schema.TaggedStruct('WSMessage.PullRes', {
|
|
|
30
30
|
export type PullRes = typeof PullRes.Type
|
|
31
31
|
|
|
32
32
|
export const PushBroadcast = Schema.TaggedStruct('WSMessage.PushBroadcast', {
|
|
33
|
-
mutationEventEncoded: MutationEvent.
|
|
33
|
+
mutationEventEncoded: MutationEvent.EncodedAny,
|
|
34
34
|
metadata: Schema.Option(SyncMetadata),
|
|
35
35
|
})
|
|
36
36
|
|
|
@@ -38,7 +38,7 @@ export type PushBroadcast = typeof PushBroadcast.Type
|
|
|
38
38
|
|
|
39
39
|
export const PushReq = Schema.TaggedStruct('WSMessage.PushReq', {
|
|
40
40
|
requestId: Schema.String,
|
|
41
|
-
batch: Schema.Array(MutationEvent.
|
|
41
|
+
batch: Schema.Array(MutationEvent.EncodedAny),
|
|
42
42
|
})
|
|
43
43
|
|
|
44
44
|
export type PushReq = typeof PushReq.Type
|
package/src/sync-impl/ws-impl.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference lib="dom" />
|
|
2
2
|
|
|
3
|
-
import type { SyncBackend } from '@livestore/common'
|
|
3
|
+
import type { SyncBackend, SyncBackendOptionsBase } from '@livestore/common'
|
|
4
4
|
import { InvalidPullError, InvalidPushError } from '@livestore/common'
|
|
5
5
|
import { pick } from '@livestore/utils'
|
|
6
6
|
import type { Scope } from '@livestore/utils/effect'
|
|
@@ -18,18 +18,26 @@ import {
|
|
|
18
18
|
} from '@livestore/utils/effect'
|
|
19
19
|
import { nanoid } from '@livestore/utils/nanoid'
|
|
20
20
|
|
|
21
|
-
import { WSMessage } from '../common/
|
|
21
|
+
import { WSMessage } from '../common/index.js'
|
|
22
22
|
import type { SyncMetadata } from '../common/ws-message-types.js'
|
|
23
23
|
|
|
24
|
-
export interface WsSyncOptions {
|
|
24
|
+
export interface WsSyncOptions extends SyncBackendOptionsBase {
|
|
25
|
+
type: 'cf'
|
|
25
26
|
url: string
|
|
26
|
-
|
|
27
|
+
roomId: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface LiveStoreGlobalCf {
|
|
31
|
+
syncBackend: WsSyncOptions
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
declare global {
|
|
35
|
+
interface LiveStoreGlobal extends LiveStoreGlobalCf {}
|
|
27
36
|
}
|
|
28
37
|
|
|
29
38
|
export const makeWsSync = (options: WsSyncOptions): Effect.Effect<SyncBackend<SyncMetadata>, never, Scope.Scope> =>
|
|
30
39
|
Effect.gen(function* () {
|
|
31
|
-
|
|
32
|
-
const wsUrl = `${options.url}/websocket?storeId=${options.storeId}`
|
|
40
|
+
const wsUrl = `${options.url}/websocket?room=${options.roomId}`
|
|
33
41
|
|
|
34
42
|
const { isConnected, incomingMessages, send } = yield* connect(wsUrl)
|
|
35
43
|
|
|
@@ -119,8 +127,8 @@ const connect = (wsUrl: string) =>
|
|
|
119
127
|
`Sending message: ${message._tag}`,
|
|
120
128
|
message._tag === 'WSMessage.PushReq'
|
|
121
129
|
? {
|
|
122
|
-
id: message.batch[0]!.id,
|
|
123
|
-
parentId: message.batch[0]!.parentId,
|
|
130
|
+
id: message.batch[0]!.id.global,
|
|
131
|
+
parentId: message.batch[0]!.parentId.global,
|
|
124
132
|
batchLength: message.batch.length,
|
|
125
133
|
}
|
|
126
134
|
: message._tag === 'WSMessage.PullReq'
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"make-worker.d.ts","sourceRoot":"","sources":["../../src/cf-worker/make-worker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,qBAAqB,CAAA;AAE9C,MAAM,MAAM,QAAQ,GAAG;IACrB,KAAK,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;CAChF,CAAA;AAED,eAAO,MAAM,UAAU,QAAO,QAkC7B,CAAA"}
|