@livestore/sync-cf 0.3.0-dev.10 → 0.3.0-dev.2
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 +128 -149
- 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 -212
- 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,274 +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
|
-
export const PERSISTENCE_FORMAT_VERSION = 2
|
|
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)
|
|
38
36
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export type MakeDurableObjectClass = (options?: MakeDurableObjectClassOptions) => {
|
|
45
|
-
new (ctx: DurableObjectState, env: Env): DurableObject<Env>
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
49
|
-
return class WebSocketServerBase extends DurableObject<Env> {
|
|
50
|
-
storage: SyncStorage | undefined = undefined
|
|
51
|
-
|
|
52
|
-
constructor(ctx: DurableObjectState, env: Env) {
|
|
53
|
-
super(ctx, env)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
fetch = async (request: Request) =>
|
|
57
|
-
Effect.gen(this, function* () {
|
|
58
|
-
if (this.storage === undefined) {
|
|
59
|
-
const storeId = getStoreId(request)
|
|
60
|
-
const dbName = `mutation_log_${PERSISTENCE_FORMAT_VERSION}_${toValidTableName(storeId)}`
|
|
61
|
-
this.storage = makeStorage(this.ctx, this.env, dbName)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const { 0: client, 1: server } = new WebSocketPair()
|
|
37
|
+
constructor(ctx: DurableObjectState, env: Env) {
|
|
38
|
+
super(ctx, env)
|
|
39
|
+
}
|
|
65
40
|
|
|
66
|
-
|
|
41
|
+
fetch = async (_request: Request) =>
|
|
42
|
+
Effect.gen(this, function* () {
|
|
43
|
+
const { 0: client, 1: server } = new WebSocketPair()
|
|
67
44
|
|
|
68
|
-
|
|
45
|
+
// See https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server
|
|
69
46
|
|
|
70
|
-
|
|
71
|
-
new WebSocketRequestResponsePair(
|
|
72
|
-
encodeIncomingMessage(WSMessage.Ping.make({ requestId: 'ping' })),
|
|
73
|
-
encodeOutgoingMessage(WSMessage.Pong.make({ requestId: 'ping' })),
|
|
74
|
-
),
|
|
75
|
-
)
|
|
47
|
+
this.ctx.acceptWebSocket(server)
|
|
76
48
|
|
|
77
|
-
|
|
78
|
-
|
|
49
|
+
this.ctx.setWebSocketAutoResponse(
|
|
50
|
+
new WebSocketRequestResponsePair(
|
|
51
|
+
encodeIncomingMessage(WSMessage.Ping.make({ requestId: 'ping' })),
|
|
52
|
+
encodeOutgoingMessage(WSMessage.Pong.make({ requestId: 'ping' })),
|
|
53
|
+
),
|
|
54
|
+
)
|
|
79
55
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
webSocket: client,
|
|
83
|
-
})
|
|
84
|
-
}).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`)
|
|
85
58
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
59
|
+
return new Response(null, {
|
|
60
|
+
status: 101,
|
|
61
|
+
webSocket: client,
|
|
62
|
+
})
|
|
63
|
+
}).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
|
|
89
64
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
65
|
+
webSocketMessage = (ws: WebSocketClient, message: ArrayBuffer | string) =>
|
|
66
|
+
Effect.gen(this, function* () {
|
|
67
|
+
const decodedMessageRes = decodeIncomingMessage(message)
|
|
94
68
|
|
|
95
|
-
|
|
96
|
-
|
|
69
|
+
if (decodedMessageRes._tag === 'Left') {
|
|
70
|
+
console.error('Invalid message received', decodedMessageRes.left)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
97
73
|
|
|
98
|
-
|
|
74
|
+
const decodedMessage = decodedMessageRes.right
|
|
75
|
+
const requestId = decodedMessage.requestId
|
|
99
76
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
switch (decodedMessage._tag) {
|
|
106
|
-
case 'WSMessage.PullReq': {
|
|
107
|
-
if (options?.onPull) {
|
|
108
|
-
yield* Effect.tryAll(() => options.onPull!(decodedMessage))
|
|
109
|
-
}
|
|
77
|
+
try {
|
|
78
|
+
switch (decodedMessage._tag) {
|
|
79
|
+
case 'WSMessage.PullReq': {
|
|
80
|
+
const cursor = decodedMessage.cursor
|
|
81
|
+
const CHUNK_SIZE = 100
|
|
110
82
|
|
|
111
|
-
|
|
112
|
-
|
|
83
|
+
// TODO use streaming
|
|
84
|
+
const remainingEvents = [...(yield* Effect.promise(() => this.storage.getEvents(cursor)))]
|
|
113
85
|
|
|
114
|
-
|
|
115
|
-
|
|
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)
|
|
116
89
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
ws.send(
|
|
122
|
-
encodeOutgoingMessage(
|
|
123
|
-
WSMessage.PullRes.make({ events, remaining: remainingEvents.length, requestId }),
|
|
124
|
-
),
|
|
125
|
-
)
|
|
90
|
+
ws.send(
|
|
91
|
+
encodeOutgoingMessage(WSMessage.PullRes.make({ events, remaining: remainingEvents.length, requestId })),
|
|
92
|
+
)
|
|
126
93
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
94
|
+
if (remainingEvents.length === 0) {
|
|
95
|
+
break
|
|
130
96
|
}
|
|
131
|
-
|
|
132
|
-
break
|
|
133
97
|
}
|
|
134
|
-
case 'WSMessage.PushReq': {
|
|
135
|
-
if (options?.onPush) {
|
|
136
|
-
yield* Effect.tryAll(() => options.onPush!(decodedMessage))
|
|
137
|
-
}
|
|
138
98
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
118
|
+
}
|
|
142
119
|
|
|
143
|
-
|
|
144
|
-
for (const mutationEventEncoded of decodedMessage.batch) {
|
|
145
|
-
if (mutationEventEncoded.parentId !== expectedParentId + i) {
|
|
146
|
-
const err = WSMessage.Error.make({
|
|
147
|
-
message: `Invalid parent id. Received ${mutationEventEncoded.parentId} but expected ${expectedParentId}`,
|
|
148
|
-
requestId,
|
|
149
|
-
})
|
|
120
|
+
// TODO handle clientId unique conflict
|
|
150
121
|
|
|
151
|
-
|
|
122
|
+
const createdAt = new Date().toISOString()
|
|
152
123
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
124
|
+
// NOTE we're currently not blocking on this to allow broadcasting right away
|
|
125
|
+
const storePromise = this.storage.appendEvent(mutationEventEncoded, createdAt)
|
|
156
126
|
|
|
157
|
-
|
|
127
|
+
ws.send(
|
|
128
|
+
encodeOutgoingMessage(
|
|
129
|
+
WSMessage.PushAck.make({ mutationId: mutationEventEncoded.id.global, requestId }),
|
|
130
|
+
),
|
|
131
|
+
)
|
|
158
132
|
|
|
159
|
-
|
|
133
|
+
// console.debug(`Broadcasting mutation event to ${this.subscribedWebSockets.size} clients`)
|
|
160
134
|
|
|
161
|
-
|
|
162
|
-
const storePromise = storage.appendEvent(mutationEventEncoded, createdAt)
|
|
135
|
+
const connectedClients = this.ctx.getWebSockets()
|
|
163
136
|
|
|
164
|
-
|
|
165
|
-
|
|
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
|
+
}),
|
|
166
144
|
)
|
|
167
145
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const broadcastMessage = encodeOutgoingMessage(
|
|
174
|
-
// TODO refactor to batch api
|
|
175
|
-
WSMessage.PushBroadcast.make({
|
|
176
|
-
mutationEventEncoded,
|
|
177
|
-
metadata: Option.some({ createdAt }),
|
|
178
|
-
}),
|
|
179
|
-
)
|
|
180
|
-
|
|
181
|
-
for (const conn of connectedClients) {
|
|
182
|
-
console.log('Broadcasting to client', conn === ws ? 'self' : 'other')
|
|
183
|
-
// if (conn !== ws) {
|
|
184
|
-
conn.send(broadcastMessage)
|
|
185
|
-
// }
|
|
186
|
-
}
|
|
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
|
+
// }
|
|
187
151
|
}
|
|
152
|
+
}
|
|
188
153
|
|
|
189
|
-
|
|
154
|
+
yield* Effect.promise(() => storePromise)
|
|
190
155
|
|
|
191
|
-
|
|
192
|
-
|
|
156
|
+
i++
|
|
157
|
+
}
|
|
193
158
|
|
|
194
|
-
|
|
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
|
|
195
165
|
}
|
|
196
|
-
case 'WSMessage.AdminResetRoomReq': {
|
|
197
|
-
if (decodedMessage.adminSecret !== this.env.ADMIN_SECRET) {
|
|
198
|
-
ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: 'Invalid admin secret', requestId })))
|
|
199
|
-
return
|
|
200
|
-
}
|
|
201
166
|
|
|
202
|
-
|
|
203
|
-
|
|
167
|
+
yield* Effect.promise(() => this.storage.resetRoom())
|
|
168
|
+
ws.send(encodeOutgoingMessage(WSMessage.AdminResetRoomRes.make({ requestId })))
|
|
204
169
|
|
|
205
|
-
|
|
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
|
|
206
176
|
}
|
|
207
|
-
case 'WSMessage.AdminInfoReq': {
|
|
208
|
-
if (decodedMessage.adminSecret !== this.env.ADMIN_SECRET) {
|
|
209
|
-
ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: 'Invalid admin secret', requestId })))
|
|
210
|
-
return
|
|
211
|
-
}
|
|
212
177
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
178
|
+
ws.send(
|
|
179
|
+
encodeOutgoingMessage(
|
|
180
|
+
WSMessage.AdminInfoRes.make({ requestId, info: { durableObjectId: this.ctx.id.toString() } }),
|
|
181
|
+
),
|
|
182
|
+
)
|
|
218
183
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
default: {
|
|
187
|
+
console.error('unsupported message', decodedMessage)
|
|
188
|
+
return shouldNeverHappen()
|
|
225
189
|
}
|
|
226
|
-
} catch (error: any) {
|
|
227
|
-
ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: error.message, requestId })))
|
|
228
190
|
}
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
)
|
|
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
|
+
)
|
|
236
201
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
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')
|
|
241
205
|
}
|
|
242
206
|
}
|
|
243
207
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
getEvents: (
|
|
248
|
-
cursor: number | undefined,
|
|
249
|
-
) => Promise<
|
|
250
|
-
ReadonlyArray<{ mutationEventEncoded: MutationEvent.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> }>
|
|
251
|
-
>
|
|
252
|
-
appendEvent: (event: MutationEvent.AnyEncodedGlobal, createdAt: string) => Promise<void>
|
|
253
|
-
resetRoom: () => Promise<void>
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const makeStorage = (ctx: DurableObjectState, env: Env, dbName: string): SyncStorage => {
|
|
257
|
-
const getLatestEvent = async (): Promise<MutationEvent.AnyEncodedGlobal | undefined> => {
|
|
258
|
-
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()
|
|
259
211
|
if (rawEvents.error) {
|
|
260
212
|
throw new Error(rawEvents.error)
|
|
261
213
|
}
|
|
262
|
-
const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results)
|
|
263
|
-
|
|
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
|
+
}))
|
|
264
220
|
return events[0]
|
|
265
221
|
}
|
|
266
222
|
|
|
267
223
|
const getEvents = async (
|
|
268
224
|
cursor: number | undefined,
|
|
269
225
|
): Promise<
|
|
270
|
-
ReadonlyArray<{ mutationEventEncoded: MutationEvent.
|
|
226
|
+
ReadonlyArray<{ mutationEventEncoded: MutationEvent.AnyEncoded; metadata: Option.Option<SyncMetadata> }>
|
|
271
227
|
> => {
|
|
272
|
-
const whereClause = cursor === undefined ? '' : `WHERE
|
|
273
|
-
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`
|
|
274
230
|
// TODO handle case where `cursor` was not found
|
|
275
231
|
const rawEvents = await env.DB.prepare(sql).all()
|
|
276
232
|
if (rawEvents.error) {
|
|
277
233
|
throw new Error(rawEvents.error)
|
|
278
234
|
}
|
|
279
235
|
const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results).map(
|
|
280
|
-
({ createdAt, ...
|
|
281
|
-
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
|
+
},
|
|
282
243
|
metadata: Option.some({ createdAt }),
|
|
283
244
|
}),
|
|
284
245
|
)
|
|
285
246
|
return events
|
|
286
247
|
}
|
|
287
248
|
|
|
288
|
-
const appendEvent = async (event: MutationEvent.
|
|
289
|
-
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 (?, ?, ?, ?, ?)`
|
|
290
251
|
await env.DB.prepare(sql)
|
|
291
|
-
.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)
|
|
292
253
|
.run()
|
|
293
254
|
}
|
|
294
255
|
|
|
@@ -296,17 +257,5 @@ const makeStorage = (ctx: DurableObjectState, env: Env, dbName: string): SyncSto
|
|
|
296
257
|
await ctx.storage.deleteAll()
|
|
297
258
|
}
|
|
298
259
|
|
|
299
|
-
return {
|
|
260
|
+
return { getLatestEvent, getEvents, appendEvent, resetRoom }
|
|
300
261
|
}
|
|
301
|
-
|
|
302
|
-
const getStoreId = (request: Request) => {
|
|
303
|
-
const url = new URL(request.url)
|
|
304
|
-
const searchParams = url.searchParams
|
|
305
|
-
const storeId = searchParams.get('storeId')
|
|
306
|
-
if (storeId === null) {
|
|
307
|
-
throw new Error('storeId search param is required')
|
|
308
|
-
}
|
|
309
|
-
return storeId
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
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"}
|