@livestore/sync-cf 0.0.0-snapshot-d9d66b354a9f4cfae987725d38971992ff14e4ad → 0.0.0-snapshot-1d99fea7d2ce2c7a5d9ed0a3752f8a7bda6bc3db
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 +40 -34
- package/dist/cf-worker/durable-object.d.ts.map +1 -1
- package/dist/cf-worker/durable-object.js +159 -124
- package/dist/cf-worker/durable-object.js.map +1 -1
- package/dist/cf-worker/mod.d.ts +3 -0
- package/dist/cf-worker/mod.d.ts.map +1 -0
- package/dist/cf-worker/mod.js +3 -0
- package/dist/cf-worker/mod.js.map +1 -0
- package/dist/cf-worker/worker.d.ts +6 -0
- package/dist/cf-worker/worker.d.ts.map +1 -0
- package/dist/cf-worker/worker.js +29 -0
- package/dist/cf-worker/worker.js.map +1 -0
- package/dist/common/{index.js → mod.d.ts} +1 -1
- package/dist/common/mod.d.ts.map +1 -0
- package/dist/common/{index.d.ts → mod.js} +1 -1
- package/dist/common/mod.js.map +1 -0
- package/dist/common/ws-message-types.d.ts +92 -216
- 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/mod.d.ts +2 -0
- package/dist/sync-impl/mod.d.ts.map +1 -0
- package/dist/sync-impl/mod.js +2 -0
- package/dist/sync-impl/mod.js.map +1 -0
- package/dist/sync-impl/ws-impl.d.ts +5 -13
- package/dist/sync-impl/ws-impl.d.ts.map +1 -1
- package/dist/sync-impl/ws-impl.js +69 -69
- package/dist/sync-impl/ws-impl.js.map +1 -1
- package/package.json +17 -8
- package/src/cf-worker/durable-object.ts +236 -153
- package/src/cf-worker/mod.ts +2 -0
- package/src/cf-worker/worker.ts +39 -0
- package/src/common/ws-message-types.ts +18 -9
- package/src/sync-impl/ws-impl.ts +120 -121
- package/dist/cf-worker/index.d.ts +0 -8
- package/dist/cf-worker/index.d.ts.map +0 -1
- package/dist/cf-worker/index.js +0 -67
- package/dist/cf-worker/index.js.map +0 -1
- package/dist/common/index.d.ts.map +0 -1
- package/dist/common/index.js.map +0 -1
- package/dist/sync-impl/index.d.ts +0 -2
- package/dist/sync-impl/index.d.ts.map +0 -1
- package/dist/sync-impl/index.js +0 -2
- package/dist/sync-impl/index.js.map +0 -1
- package/src/cf-worker/index.ts +0 -84
- package/tsconfig.json +0 -12
- package/wrangler.toml +0 -21
- /package/src/common/{index.ts → mod.ts} +0 -0
- /package/src/sync-impl/{index.ts → mod.ts} +0 -0
|
@@ -1,13 +1,14 @@
|
|
|
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
|
-
import { WSMessage } from '../common/
|
|
7
|
+
import { WSMessage } from '../common/mod.js'
|
|
8
|
+
import type { SyncMetadata } from '../common/ws-message-types.js'
|
|
8
9
|
|
|
9
10
|
export interface Env {
|
|
10
|
-
WEBSOCKET_SERVER: DurableObjectNamespace
|
|
11
|
+
WEBSOCKET_SERVER: DurableObjectNamespace
|
|
11
12
|
DB: D1Database
|
|
12
13
|
ADMIN_SECRET: string
|
|
13
14
|
}
|
|
@@ -18,209 +19,279 @@ 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
|
-
|
|
23
|
-
|
|
24
|
+
id: DbSchema.integer({ primaryKey: true, schema: EventId.GlobalEventId }),
|
|
25
|
+
parentId: DbSchema.integer({ schema: EventId.GlobalEventId }),
|
|
24
26
|
mutation: DbSchema.text({}),
|
|
25
27
|
args: DbSchema.text({ schema: Schema.parseJson(Schema.Any) }),
|
|
28
|
+
/** ISO date format. Currently only used for debugging purposes. */
|
|
29
|
+
createdAt: DbSchema.text({}),
|
|
26
30
|
})
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
const WebSocketAttachmentSchema = Schema.parseJson(
|
|
33
|
+
Schema.Struct({
|
|
34
|
+
storeId: Schema.String,
|
|
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
|
+
}
|
|
32
49
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
50
|
+
export type MakeDurableObjectClass = (options?: MakeDurableObjectClassOptions) => {
|
|
51
|
+
new (ctx: DurableObjectState, env: Env): DurableObject<Env>
|
|
52
|
+
}
|
|
36
53
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
54
|
+
export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
55
|
+
return class WebSocketServerBase extends DurableObject<Env> {
|
|
56
|
+
constructor(ctx: DurableObjectState, env: Env) {
|
|
57
|
+
super(ctx, env)
|
|
58
|
+
}
|
|
40
59
|
|
|
41
|
-
|
|
60
|
+
fetch = async (request: Request) =>
|
|
61
|
+
Effect.gen(this, function* () {
|
|
62
|
+
const storeId = getStoreId(request)
|
|
63
|
+
const storage = makeStorage(this.ctx, this.env, storeId)
|
|
42
64
|
|
|
43
|
-
|
|
65
|
+
const { 0: client, 1: server } = new WebSocketPair()
|
|
44
66
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
encodeIncomingMessage(WSMessage.Ping.make({ requestId: 'ping' })),
|
|
48
|
-
encodeOutgoingMessage(WSMessage.Pong.make({ requestId: 'ping' })),
|
|
49
|
-
),
|
|
50
|
-
)
|
|
67
|
+
// Since we're using websocket hibernation, we need to remember the storeId for subsequent `webSocketMessage` calls
|
|
68
|
+
server.serializeAttachment(Schema.encodeSync(WebSocketAttachmentSchema)({ storeId }))
|
|
51
69
|
|
|
52
|
-
|
|
53
|
-
this.env.DB.exec(`CREATE TABLE IF NOT EXISTS ${this.dbName} (${colSpec}) strict`)
|
|
70
|
+
// See https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server
|
|
54
71
|
|
|
55
|
-
|
|
56
|
-
status: 101,
|
|
57
|
-
webSocket: client,
|
|
58
|
-
})
|
|
59
|
-
}).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
|
|
72
|
+
this.ctx.acceptWebSocket(server)
|
|
60
73
|
|
|
61
|
-
|
|
62
|
-
|
|
74
|
+
this.ctx.setWebSocketAutoResponse(
|
|
75
|
+
new WebSocketRequestResponsePair(
|
|
76
|
+
encodeIncomingMessage(WSMessage.Ping.make({ requestId: 'ping' })),
|
|
77
|
+
encodeOutgoingMessage(WSMessage.Pong.make({ requestId: 'ping' })),
|
|
78
|
+
),
|
|
79
|
+
)
|
|
63
80
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
81
|
+
const colSpec = makeColumnSpec(mutationLogTable.sqliteDef.ast)
|
|
82
|
+
this.env.DB.exec(`CREATE TABLE IF NOT EXISTS ${storage.dbName} (${colSpec}) strict`)
|
|
83
|
+
|
|
84
|
+
return new Response(null, {
|
|
85
|
+
status: 101,
|
|
86
|
+
webSocket: client,
|
|
87
|
+
})
|
|
88
|
+
}).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
|
|
89
|
+
|
|
90
|
+
webSocketMessage = (ws: WebSocketClient, message: ArrayBuffer | string) =>
|
|
91
|
+
Effect.gen(this, function* () {
|
|
92
|
+
const decodedMessageRes = decodeIncomingMessage(message)
|
|
93
|
+
|
|
94
|
+
if (decodedMessageRes._tag === 'Left') {
|
|
95
|
+
console.error('Invalid message received', decodedMessageRes.left)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { storeId } = yield* Schema.decode(WebSocketAttachmentSchema)(ws.deserializeAttachment())
|
|
100
|
+
const storage = makeStorage(this.ctx, this.env, storeId)
|
|
101
|
+
|
|
102
|
+
const decodedMessage = decodedMessageRes.right
|
|
103
|
+
const requestId = decodedMessage.requestId
|
|
68
104
|
|
|
69
|
-
|
|
70
|
-
|
|
105
|
+
try {
|
|
106
|
+
switch (decodedMessage._tag) {
|
|
107
|
+
case 'WSMessage.PullReq': {
|
|
108
|
+
if (options?.onPull) {
|
|
109
|
+
yield* Effect.tryAll(() => options.onPull!(decodedMessage))
|
|
110
|
+
}
|
|
71
111
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
case 'WSMessage.PullReq': {
|
|
75
|
-
const cursor = decodedMessage.cursor
|
|
76
|
-
const CHUNK_SIZE = 100
|
|
112
|
+
const cursor = decodedMessage.cursor
|
|
113
|
+
const CHUNK_SIZE = 100
|
|
77
114
|
|
|
78
|
-
|
|
79
|
-
|
|
115
|
+
// TODO use streaming
|
|
116
|
+
const remainingEvents = [...(yield* Effect.promise(() => storage.getEvents(cursor)))]
|
|
80
117
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const encodedEvents = Schema.encodeSync(Schema.Array(mutationEventSchemaAny))(events)
|
|
85
|
-
const hasMore = remainingEvents.length > 0
|
|
118
|
+
// NOTE we want to make sure the WS server responds at least once with `InitRes` even if `events` is empty
|
|
119
|
+
while (true) {
|
|
120
|
+
const events = remainingEvents.splice(0, CHUNK_SIZE)
|
|
86
121
|
|
|
87
|
-
|
|
122
|
+
ws.send(
|
|
123
|
+
encodeOutgoingMessage(
|
|
124
|
+
WSMessage.PullRes.make({ events, remaining: remainingEvents.length, requestId }),
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if (remainingEvents.length === 0) {
|
|
129
|
+
break
|
|
130
|
+
}
|
|
131
|
+
}
|
|
88
132
|
|
|
89
|
-
if (hasMore === false) {
|
|
90
133
|
break
|
|
91
134
|
}
|
|
92
|
-
|
|
135
|
+
case 'WSMessage.PushReq': {
|
|
136
|
+
if (options?.onPush) {
|
|
137
|
+
yield* Effect.tryAll(() => options.onPush!(decodedMessage))
|
|
138
|
+
}
|
|
93
139
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
if (decodedMessage.mutationEventEncoded.parentId.global !== expectedParentId.global) {
|
|
102
|
-
ws.send(
|
|
103
|
-
encodeOutgoingMessage(
|
|
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
|
-
}
|
|
140
|
+
// TODO check whether we could use the Durable Object storage for this to speed up the lookup
|
|
141
|
+
const latestEvent = yield* Effect.promise(() => storage.getLatestEvent())
|
|
142
|
+
const expectedParentId = latestEvent?.id ?? EventId.ROOT.global
|
|
112
143
|
|
|
113
|
-
|
|
144
|
+
let i = 0
|
|
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
|
+
})
|
|
114
151
|
|
|
115
|
-
|
|
116
|
-
const storePromise = decodedMessage.persisted
|
|
117
|
-
? this.storage.appendEvent(decodedMessage.mutationEventEncoded)
|
|
118
|
-
: Promise.resolve()
|
|
152
|
+
yield* Effect.fail(err).pipe(Effect.ignoreLogged)
|
|
119
153
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
),
|
|
124
|
-
)
|
|
154
|
+
ws.send(encodeOutgoingMessage(err))
|
|
155
|
+
return
|
|
156
|
+
}
|
|
125
157
|
|
|
126
|
-
|
|
158
|
+
// TODO handle clientId unique conflict
|
|
127
159
|
|
|
128
|
-
|
|
160
|
+
const createdAt = new Date().toISOString()
|
|
129
161
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
WSMessage.PushBroadcast.make({
|
|
133
|
-
mutationEventEncoded: decodedMessage.mutationEventEncoded,
|
|
134
|
-
persisted: decodedMessage.persisted,
|
|
135
|
-
}),
|
|
136
|
-
)
|
|
162
|
+
// NOTE we're currently not blocking on this to allow broadcasting right away
|
|
163
|
+
const storePromise = storage.appendEvent(mutationEventEncoded, createdAt)
|
|
137
164
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
conn.send(broadcastMessage)
|
|
142
|
-
// }
|
|
143
|
-
}
|
|
144
|
-
}
|
|
165
|
+
ws.send(
|
|
166
|
+
encodeOutgoingMessage(WSMessage.PushAck.make({ mutationId: mutationEventEncoded.id, requestId })),
|
|
167
|
+
)
|
|
145
168
|
|
|
146
|
-
|
|
169
|
+
// console.debug(`Broadcasting mutation event to ${this.subscribedWebSockets.size} clients`)
|
|
147
170
|
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
case 'WSMessage.AdminResetRoomReq': {
|
|
151
|
-
if (decodedMessage.adminSecret !== this.env.ADMIN_SECRET) {
|
|
152
|
-
ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: 'Invalid admin secret', requestId })))
|
|
153
|
-
return
|
|
154
|
-
}
|
|
171
|
+
const connectedClients = this.ctx.getWebSockets()
|
|
155
172
|
|
|
156
|
-
|
|
157
|
-
|
|
173
|
+
if (connectedClients.length > 0) {
|
|
174
|
+
const broadcastMessage = encodeOutgoingMessage(
|
|
175
|
+
// TODO refactor to batch api
|
|
176
|
+
WSMessage.PushBroadcast.make({
|
|
177
|
+
mutationEventEncoded,
|
|
178
|
+
metadata: Option.some({ createdAt }),
|
|
179
|
+
}),
|
|
180
|
+
)
|
|
158
181
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
+
}
|
|
188
|
+
}
|
|
166
189
|
|
|
167
|
-
|
|
168
|
-
encodeOutgoingMessage(
|
|
169
|
-
WSMessage.AdminInfoRes.make({ requestId, info: { durableObjectId: this.ctx.id.toString() } }),
|
|
170
|
-
),
|
|
171
|
-
)
|
|
190
|
+
yield* Effect.promise(() => storePromise)
|
|
172
191
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
192
|
+
i++
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
break
|
|
196
|
+
}
|
|
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
|
+
|
|
203
|
+
yield* Effect.promise(() => storage.resetRoom())
|
|
204
|
+
ws.send(encodeOutgoingMessage(WSMessage.AdminResetRoomRes.make({ requestId })))
|
|
205
|
+
|
|
206
|
+
break
|
|
207
|
+
}
|
|
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
|
+
|
|
214
|
+
ws.send(
|
|
215
|
+
encodeOutgoingMessage(
|
|
216
|
+
WSMessage.AdminInfoRes.make({ requestId, info: { durableObjectId: this.ctx.id.toString() } }),
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
break
|
|
221
|
+
}
|
|
222
|
+
default: {
|
|
223
|
+
console.error('unsupported message', decodedMessage)
|
|
224
|
+
return shouldNeverHappen()
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} catch (error: any) {
|
|
228
|
+
ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: error.message, requestId })))
|
|
178
229
|
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
|
|
230
|
+
}).pipe(
|
|
231
|
+
Effect.withSpan('@livestore/sync-cf:durable-object:webSocketMessage'),
|
|
232
|
+
Effect.tapCauseLogPretty,
|
|
233
|
+
Logger.withMinimumLogLevel(LogLevel.Debug),
|
|
234
|
+
Effect.provide(Logger.pretty),
|
|
235
|
+
Effect.runPromise,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
webSocketClose = async (ws: WebSocketClient, code: number, _reason: string, _wasClean: boolean) => {
|
|
239
|
+
// If the client closes the connection, the runtime will invoke the webSocketClose() handler.
|
|
240
|
+
ws.close(code, 'Durable Object is closing WebSocket')
|
|
182
241
|
}
|
|
183
242
|
}
|
|
243
|
+
}
|
|
184
244
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
245
|
+
type SyncStorage = {
|
|
246
|
+
dbName: string
|
|
247
|
+
getLatestEvent: () => Promise<MutationEvent.AnyEncodedGlobal | undefined>
|
|
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>
|
|
189
255
|
}
|
|
190
256
|
|
|
191
|
-
const makeStorage = (ctx: DurableObjectState, env: Env,
|
|
192
|
-
const
|
|
193
|
-
|
|
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()
|
|
194
262
|
if (rawEvents.error) {
|
|
195
263
|
throw new Error(rawEvents.error)
|
|
196
264
|
}
|
|
197
|
-
const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results)
|
|
198
|
-
|
|
199
|
-
id: { global: e.idGlobal, local: 0 },
|
|
200
|
-
parentId: { global: e.parentIdGlobal, local: 0 },
|
|
201
|
-
}))
|
|
265
|
+
const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results)
|
|
266
|
+
|
|
202
267
|
return events[0]
|
|
203
268
|
}
|
|
204
269
|
|
|
205
|
-
const getEvents = async (
|
|
206
|
-
|
|
270
|
+
const getEvents = async (
|
|
271
|
+
cursor: number | undefined,
|
|
272
|
+
): Promise<
|
|
273
|
+
ReadonlyArray<{ mutationEventEncoded: MutationEvent.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> }>
|
|
274
|
+
> => {
|
|
275
|
+
const whereClause = cursor === undefined ? '' : `WHERE id > ${cursor}`
|
|
276
|
+
const sql = `SELECT * FROM ${dbName} ${whereClause} ORDER BY id ASC`
|
|
207
277
|
// TODO handle case where `cursor` was not found
|
|
208
|
-
const rawEvents = await env.DB.prepare(
|
|
278
|
+
const rawEvents = await env.DB.prepare(sql).all()
|
|
209
279
|
if (rawEvents.error) {
|
|
210
280
|
throw new Error(rawEvents.error)
|
|
211
281
|
}
|
|
212
|
-
const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results).map(
|
|
213
|
-
...
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
282
|
+
const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results).map(
|
|
283
|
+
({ createdAt, ...mutationEventEncoded }) => ({
|
|
284
|
+
mutationEventEncoded,
|
|
285
|
+
metadata: Option.some({ createdAt }),
|
|
286
|
+
}),
|
|
287
|
+
)
|
|
217
288
|
return events
|
|
218
289
|
}
|
|
219
290
|
|
|
220
|
-
const appendEvent = async (event: MutationEvent.
|
|
221
|
-
const sql = `INSERT INTO ${dbName} (
|
|
291
|
+
const appendEvent = async (event: MutationEvent.AnyEncodedGlobal, createdAt: string) => {
|
|
292
|
+
const sql = `INSERT INTO ${dbName} (id, parentId, args, mutation, createdAt) VALUES (?, ?, ?, ?, ?)`
|
|
222
293
|
await env.DB.prepare(sql)
|
|
223
|
-
.bind(event.id
|
|
294
|
+
.bind(event.id, event.parentId, JSON.stringify(event.args), event.mutation, createdAt)
|
|
224
295
|
.run()
|
|
225
296
|
}
|
|
226
297
|
|
|
@@ -228,5 +299,17 @@ const makeStorage = (ctx: DurableObjectState, env: Env, dbName: string) => {
|
|
|
228
299
|
await ctx.storage.deleteAll()
|
|
229
300
|
}
|
|
230
301
|
|
|
231
|
-
return { getLatestEvent, getEvents, appendEvent, resetRoom }
|
|
302
|
+
return { dbName, getLatestEvent, getEvents, appendEvent, resetRoom }
|
|
232
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
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const toValidTableName = (str: string) => str.replaceAll(/[^a-zA-Z0-9]/g, '_')
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Env } from './durable-object.js'
|
|
2
|
+
|
|
3
|
+
export type CFWorker = {
|
|
4
|
+
fetch: (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response>
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const makeWorker = (): CFWorker => {
|
|
8
|
+
return {
|
|
9
|
+
fetch: async (request, env, _ctx) => {
|
|
10
|
+
const url = new URL(request.url)
|
|
11
|
+
const searchParams = url.searchParams
|
|
12
|
+
const storeId = searchParams.get('storeId')
|
|
13
|
+
|
|
14
|
+
if (storeId === null) {
|
|
15
|
+
return new Response('storeId search param is required', { status: 400 })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const id = env.WEBSOCKET_SERVER.idFromName(storeId)
|
|
19
|
+
const durableObject = env.WEBSOCKET_SERVER.get(id)
|
|
20
|
+
|
|
21
|
+
if (url.pathname.endsWith('/websocket')) {
|
|
22
|
+
const upgradeHeader = request.headers.get('Upgrade')
|
|
23
|
+
if (!upgradeHeader || upgradeHeader !== 'websocket') {
|
|
24
|
+
return new Response('Durable Object expected Upgrade: websocket', { status: 426 })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return durableObject.fetch(request)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return new Response(null, {
|
|
31
|
+
status: 400,
|
|
32
|
+
statusText: 'Bad Request',
|
|
33
|
+
headers: {
|
|
34
|
+
'Content-Type': 'text/plain',
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -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.AnyEncodedGlobal,
|
|
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.AnyEncodedGlobal,
|
|
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.AnyEncodedGlobal),
|
|
33
42
|
})
|
|
34
43
|
|
|
35
44
|
export type PushReq = typeof PushReq.Type
|