@livestore/sync-cf 0.3.0-dev.1 → 0.3.0-dev.11
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 +27 -34
- package/dist/cf-worker/durable-object.d.ts.map +1 -1
- package/dist/cf-worker/durable-object.js +150 -129
- package/dist/cf-worker/durable-object.js.map +1 -1
- package/dist/cf-worker/index.d.ts +1 -6
- package/dist/cf-worker/index.d.ts.map +1 -1
- package/dist/cf-worker/index.js +30 -64
- package/dist/cf-worker/index.js.map +1 -1
- package/dist/cf-worker/make-worker.d.ts +6 -0
- package/dist/cf-worker/make-worker.d.ts.map +1 -0
- package/dist/cf-worker/make-worker.js +31 -0
- package/dist/cf-worker/make-worker.js.map +1 -0
- 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/types.d.ts +2 -0
- package/dist/cf-worker/types.d.ts.map +1 -0
- package/dist/cf-worker/types.js +2 -0
- package/dist/cf-worker/types.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/mod.d.ts +2 -0
- package/dist/common/mod.d.ts.map +1 -0
- package/dist/common/mod.js +2 -0
- package/dist/common/mod.js.map +1 -0
- package/dist/common/ws-message-types.d.ts +54 -207
- 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/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 +3 -12
- package/dist/sync-impl/ws-impl.d.ts.map +1 -1
- package/dist/sync-impl/ws-impl.js +5 -4
- package/dist/sync-impl/ws-impl.js.map +1 -1
- package/package.json +15 -13
- package/src/cf-worker/durable-object.ts +215 -161
- package/src/cf-worker/mod.ts +2 -0
- package/src/cf-worker/worker.ts +39 -0
- package/src/common/ws-message-types.ts +3 -3
- package/src/sync-impl/ws-impl.ts +8 -16
- package/src/cf-worker/index.ts +0 -84
- /package/src/common/{index.ts → mod.ts} +0 -0
- /package/src/sync-impl/{index.ts → mod.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,33 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livestore/sync-cf",
|
|
3
|
-
"version": "0.3.0-dev.
|
|
3
|
+
"version": "0.3.0-dev.11",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"sideEffects": false,
|
|
5
6
|
"exports": {
|
|
6
7
|
".": {
|
|
7
|
-
"types": "./dist/sync-impl/
|
|
8
|
-
"default": "./dist/sync-impl/
|
|
8
|
+
"types": "./dist/sync-impl/mod.d.ts",
|
|
9
|
+
"default": "./dist/sync-impl/mod.js"
|
|
10
|
+
},
|
|
11
|
+
"./cf-worker": {
|
|
12
|
+
"types": "./dist/cf-worker/mod.d.ts",
|
|
13
|
+
"default": "./dist/cf-worker/mod.js"
|
|
9
14
|
}
|
|
10
15
|
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@livestore/common": "0.3.0-dev.11",
|
|
18
|
+
"@livestore/utils": "0.3.0-dev.11"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@cloudflare/workers-types": "^4.20241022.0"
|
|
22
|
+
},
|
|
11
23
|
"files": [
|
|
12
24
|
"dist",
|
|
13
25
|
"src",
|
|
14
26
|
"package.json",
|
|
15
27
|
"README.md"
|
|
16
28
|
],
|
|
17
|
-
"dependencies": {
|
|
18
|
-
"@livestore/common": "0.3.0-dev.1",
|
|
19
|
-
"@livestore/utils": "0.3.0-dev.1"
|
|
20
|
-
},
|
|
21
|
-
"devDependencies": {
|
|
22
|
-
"@cloudflare/workers-types": "4.20241022.0",
|
|
23
|
-
"wrangler": "^3.84.0"
|
|
24
|
-
},
|
|
25
29
|
"publishConfig": {
|
|
26
30
|
"access": "public"
|
|
27
31
|
},
|
|
28
32
|
"scripts": {
|
|
29
|
-
"dev": "wrangler dev --inspector-port 9230",
|
|
30
|
-
"deploy": "wrangler publish",
|
|
31
33
|
"test": "echo 'No tests yet'"
|
|
32
34
|
}
|
|
33
35
|
}
|
|
@@ -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/mod.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
|
|
12
12
|
DB: D1Database
|
|
13
13
|
ADMIN_SECRET: string
|
|
14
14
|
}
|
|
@@ -21,235 +21,277 @@ 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
|
+
id: DbSchema.integer({ primaryKey: true, schema: EventId.GlobalEventId }),
|
|
25
|
+
parentId: DbSchema.integer({ schema: EventId.GlobalEventId }),
|
|
26
26
|
mutation: DbSchema.text({}),
|
|
27
27
|
args: DbSchema.text({ schema: Schema.parseJson(Schema.Any) }),
|
|
28
|
-
/** ISO date format */
|
|
28
|
+
/** ISO date format. Currently only used for debugging purposes. */
|
|
29
29
|
createdAt: DbSchema.text({}),
|
|
30
30
|
})
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
}
|
|
36
49
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
50
|
+
export type MakeDurableObjectClass = (options?: MakeDurableObjectClassOptions) => {
|
|
51
|
+
new (ctx: DurableObjectState, env: Env): DurableObject<Env>
|
|
52
|
+
}
|
|
40
53
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
54
|
+
export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
55
|
+
return class WebSocketServerBase extends DurableObject<Env> {
|
|
56
|
+
constructor(ctx: DurableObjectState, env: Env) {
|
|
57
|
+
super(ctx, env)
|
|
58
|
+
}
|
|
44
59
|
|
|
45
|
-
|
|
60
|
+
fetch = async (request: Request) =>
|
|
61
|
+
Effect.gen(this, function* () {
|
|
62
|
+
const storeId = getStoreId(request)
|
|
63
|
+
const storage = makeStorage(this.ctx, this.env, storeId)
|
|
46
64
|
|
|
47
|
-
|
|
65
|
+
const { 0: client, 1: server } = new WebSocketPair()
|
|
48
66
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
encodeIncomingMessage(WSMessage.Ping.make({ requestId: 'ping' })),
|
|
52
|
-
encodeOutgoingMessage(WSMessage.Pong.make({ requestId: 'ping' })),
|
|
53
|
-
),
|
|
54
|
-
)
|
|
67
|
+
// Since we're using websocket hibernation, we need to remember the storeId for subsequent `webSocketMessage` calls
|
|
68
|
+
server.serializeAttachment(Schema.encodeSync(WebSocketAttachmentSchema)({ storeId }))
|
|
55
69
|
|
|
56
|
-
|
|
57
|
-
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
|
|
58
71
|
|
|
59
|
-
|
|
60
|
-
status: 101,
|
|
61
|
-
webSocket: client,
|
|
62
|
-
})
|
|
63
|
-
}).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
|
|
72
|
+
this.ctx.acceptWebSocket(server)
|
|
64
73
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
this.ctx.setWebSocketAutoResponse(
|
|
75
|
+
new WebSocketRequestResponsePair(
|
|
76
|
+
encodeIncomingMessage(WSMessage.Ping.make({ requestId: 'ping' })),
|
|
77
|
+
encodeOutgoingMessage(WSMessage.Pong.make({ requestId: 'ping' })),
|
|
78
|
+
),
|
|
79
|
+
)
|
|
68
80
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return
|
|
72
|
-
}
|
|
81
|
+
const colSpec = makeColumnSpec(mutationLogTable.sqliteDef.ast)
|
|
82
|
+
this.env.DB.exec(`CREATE TABLE IF NOT EXISTS ${storage.dbName} (${colSpec}) strict`)
|
|
73
83
|
|
|
74
|
-
|
|
75
|
-
|
|
84
|
+
return new Response(null, {
|
|
85
|
+
status: 101,
|
|
86
|
+
webSocket: client,
|
|
87
|
+
})
|
|
88
|
+
}).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
|
|
76
89
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const cursor = decodedMessage.cursor
|
|
81
|
-
const CHUNK_SIZE = 100
|
|
90
|
+
webSocketMessage = (ws: WebSocketClient, message: ArrayBuffer | string) =>
|
|
91
|
+
Effect.gen(this, function* () {
|
|
92
|
+
const decodedMessageRes = decodeIncomingMessage(message)
|
|
82
93
|
|
|
83
|
-
|
|
84
|
-
|
|
94
|
+
if (decodedMessageRes._tag === 'Left') {
|
|
95
|
+
console.error('Invalid message received', decodedMessageRes.left)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
85
98
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const events = remainingEvents.splice(0, CHUNK_SIZE)
|
|
99
|
+
const { storeId } = yield* Schema.decode(WebSocketAttachmentSchema)(ws.deserializeAttachment())
|
|
100
|
+
const storage = makeStorage(this.ctx, this.env, storeId)
|
|
89
101
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
)
|
|
102
|
+
const decodedMessage = decodedMessageRes.right
|
|
103
|
+
const requestId = decodedMessage.requestId
|
|
93
104
|
|
|
94
|
-
|
|
95
|
-
|
|
105
|
+
try {
|
|
106
|
+
switch (decodedMessage._tag) {
|
|
107
|
+
case 'WSMessage.PullReq': {
|
|
108
|
+
if (options?.onPull) {
|
|
109
|
+
yield* Effect.tryAll(() => options.onPull!(decodedMessage))
|
|
96
110
|
}
|
|
97
|
-
}
|
|
98
111
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
112
|
+
const cursor = decodedMessage.cursor
|
|
113
|
+
const CHUNK_SIZE = 100
|
|
114
|
+
|
|
115
|
+
// TODO use streaming
|
|
116
|
+
const remainingEvents = [...(yield* Effect.promise(() => storage.getEvents(cursor)))]
|
|
117
|
+
|
|
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)
|
|
121
|
+
|
|
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
|
+
}
|
|
118
131
|
}
|
|
119
132
|
|
|
120
|
-
|
|
133
|
+
break
|
|
134
|
+
}
|
|
135
|
+
case 'WSMessage.PushReq': {
|
|
136
|
+
if (options?.onPush) {
|
|
137
|
+
yield* Effect.tryAll(() => options.onPush!(decodedMessage))
|
|
138
|
+
}
|
|
121
139
|
|
|
122
|
-
|
|
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
|
|
123
143
|
|
|
124
|
-
|
|
125
|
-
const
|
|
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
|
+
})
|
|
126
151
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
152
|
+
yield* Effect.fail(err).pipe(Effect.ignoreLogged)
|
|
153
|
+
|
|
154
|
+
ws.send(encodeOutgoingMessage(err))
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// TODO handle clientId unique conflict
|
|
132
159
|
|
|
133
|
-
|
|
160
|
+
const createdAt = new Date().toISOString()
|
|
134
161
|
|
|
135
|
-
|
|
162
|
+
// NOTE we're currently not blocking on this to allow broadcasting right away
|
|
163
|
+
const storePromise = storage.appendEvent(mutationEventEncoded, createdAt)
|
|
136
164
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
// TODO refactor to batch api
|
|
140
|
-
WSMessage.PushBroadcast.make({
|
|
141
|
-
mutationEventEncoded,
|
|
142
|
-
metadata: Option.some({ createdAt }),
|
|
143
|
-
}),
|
|
165
|
+
ws.send(
|
|
166
|
+
encodeOutgoingMessage(WSMessage.PushAck.make({ mutationId: mutationEventEncoded.id, requestId })),
|
|
144
167
|
)
|
|
145
168
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
169
|
+
// console.debug(`Broadcasting mutation event to ${this.subscribedWebSockets.size} clients`)
|
|
170
|
+
|
|
171
|
+
const connectedClients = this.ctx.getWebSockets()
|
|
172
|
+
|
|
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
|
+
)
|
|
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
|
+
}
|
|
151
188
|
}
|
|
152
|
-
}
|
|
153
189
|
|
|
154
|
-
|
|
190
|
+
yield* Effect.promise(() => storePromise)
|
|
155
191
|
|
|
156
|
-
|
|
157
|
-
|
|
192
|
+
i++
|
|
193
|
+
}
|
|
158
194
|
|
|
159
|
-
|
|
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
|
+
break
|
|
165
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
|
+
}
|
|
166
202
|
|
|
167
|
-
|
|
168
|
-
|
|
203
|
+
yield* Effect.promise(() => storage.resetRoom())
|
|
204
|
+
ws.send(encodeOutgoingMessage(WSMessage.AdminResetRoomRes.make({ requestId })))
|
|
169
205
|
|
|
170
|
-
|
|
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
|
+
break
|
|
176
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
|
+
}
|
|
177
213
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
214
|
+
ws.send(
|
|
215
|
+
encodeOutgoingMessage(
|
|
216
|
+
WSMessage.AdminInfoRes.make({ requestId, info: { durableObjectId: this.ctx.id.toString() } }),
|
|
217
|
+
),
|
|
218
|
+
)
|
|
183
219
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
220
|
+
break
|
|
221
|
+
}
|
|
222
|
+
default: {
|
|
223
|
+
console.error('unsupported message', decodedMessage)
|
|
224
|
+
return shouldNeverHappen()
|
|
225
|
+
}
|
|
189
226
|
}
|
|
227
|
+
} catch (error: any) {
|
|
228
|
+
ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: error.message, requestId })))
|
|
190
229
|
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
Effect.provide(Logger.pretty),
|
|
199
|
-
Effect.runPromise,
|
|
200
|
-
)
|
|
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
|
+
)
|
|
201
237
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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')
|
|
241
|
+
}
|
|
205
242
|
}
|
|
206
243
|
}
|
|
207
244
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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>
|
|
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()
|
|
211
262
|
if (rawEvents.error) {
|
|
212
263
|
throw new Error(rawEvents.error)
|
|
213
264
|
}
|
|
214
|
-
const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results)
|
|
215
|
-
|
|
216
|
-
// TODO remove local ids
|
|
217
|
-
id: { global: e.idGlobal, local: 0 },
|
|
218
|
-
parentId: { global: e.parentIdGlobal, local: 0 },
|
|
219
|
-
}))
|
|
265
|
+
const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results)
|
|
266
|
+
|
|
220
267
|
return events[0]
|
|
221
268
|
}
|
|
222
269
|
|
|
223
270
|
const getEvents = async (
|
|
224
271
|
cursor: number | undefined,
|
|
225
272
|
): Promise<
|
|
226
|
-
ReadonlyArray<{ mutationEventEncoded: MutationEvent.
|
|
273
|
+
ReadonlyArray<{ mutationEventEncoded: MutationEvent.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> }>
|
|
227
274
|
> => {
|
|
228
|
-
const whereClause = cursor === undefined ? '' : `WHERE
|
|
229
|
-
const sql = `SELECT * FROM ${dbName} ${whereClause} ORDER BY
|
|
275
|
+
const whereClause = cursor === undefined ? '' : `WHERE id > ${cursor}`
|
|
276
|
+
const sql = `SELECT * FROM ${dbName} ${whereClause} ORDER BY id ASC`
|
|
230
277
|
// TODO handle case where `cursor` was not found
|
|
231
278
|
const rawEvents = await env.DB.prepare(sql).all()
|
|
232
279
|
if (rawEvents.error) {
|
|
233
280
|
throw new Error(rawEvents.error)
|
|
234
281
|
}
|
|
235
282
|
const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results).map(
|
|
236
|
-
({ createdAt, ...
|
|
237
|
-
mutationEventEncoded
|
|
238
|
-
...e,
|
|
239
|
-
// TODO remove local ids
|
|
240
|
-
id: { global: e.idGlobal, local: 0 },
|
|
241
|
-
parentId: { global: e.parentIdGlobal, local: 0 },
|
|
242
|
-
},
|
|
283
|
+
({ createdAt, ...mutationEventEncoded }) => ({
|
|
284
|
+
mutationEventEncoded,
|
|
243
285
|
metadata: Option.some({ createdAt }),
|
|
244
286
|
}),
|
|
245
287
|
)
|
|
246
288
|
return events
|
|
247
289
|
}
|
|
248
290
|
|
|
249
|
-
const appendEvent = async (event: MutationEvent.
|
|
250
|
-
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 (?, ?, ?, ?, ?)`
|
|
251
293
|
await env.DB.prepare(sql)
|
|
252
|
-
.bind(event.id
|
|
294
|
+
.bind(event.id, event.parentId, JSON.stringify(event.args), event.mutation, createdAt)
|
|
253
295
|
.run()
|
|
254
296
|
}
|
|
255
297
|
|
|
@@ -257,5 +299,17 @@ const makeStorage = (ctx: DurableObjectState, env: Env, dbName: string) => {
|
|
|
257
299
|
await ctx.storage.deleteAll()
|
|
258
300
|
}
|
|
259
301
|
|
|
260
|
-
return { getLatestEvent, getEvents, appendEvent, resetRoom }
|
|
302
|
+
return { dbName, getLatestEvent, getEvents, appendEvent, resetRoom }
|
|
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
|
|
261
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
|
+
}
|
|
@@ -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.AnyEncodedGlobal,
|
|
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.AnyEncodedGlobal,
|
|
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.AnyEncodedGlobal),
|
|
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
|
|
3
|
+
import type { SyncBackend } 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,26 +18,18 @@ 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/mod.js'
|
|
22
22
|
import type { SyncMetadata } from '../common/ws-message-types.js'
|
|
23
23
|
|
|
24
|
-
export interface WsSyncOptions
|
|
25
|
-
type: 'cf'
|
|
24
|
+
export interface WsSyncOptions {
|
|
26
25
|
url: string
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
interface LiveStoreGlobalCf {
|
|
31
|
-
syncBackend: WsSyncOptions
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
declare global {
|
|
35
|
-
interface LiveStoreGlobal extends LiveStoreGlobalCf {}
|
|
26
|
+
storeId: string
|
|
36
27
|
}
|
|
37
28
|
|
|
38
29
|
export const makeWsSync = (options: WsSyncOptions): Effect.Effect<SyncBackend<SyncMetadata>, never, Scope.Scope> =>
|
|
39
30
|
Effect.gen(function* () {
|
|
40
|
-
|
|
31
|
+
// TODO also allow for auth scenarios
|
|
32
|
+
const wsUrl = `${options.url}/websocket?storeId=${options.storeId}`
|
|
41
33
|
|
|
42
34
|
const { isConnected, incomingMessages, send } = yield* connect(wsUrl)
|
|
43
35
|
|
|
@@ -127,8 +119,8 @@ const connect = (wsUrl: string) =>
|
|
|
127
119
|
`Sending message: ${message._tag}`,
|
|
128
120
|
message._tag === 'WSMessage.PushReq'
|
|
129
121
|
? {
|
|
130
|
-
id: message.batch[0]!.id
|
|
131
|
-
parentId: message.batch[0]!.parentId
|
|
122
|
+
id: message.batch[0]!.id,
|
|
123
|
+
parentId: message.batch[0]!.parentId,
|
|
132
124
|
batchLength: message.batch.length,
|
|
133
125
|
}
|
|
134
126
|
: message._tag === 'WSMessage.PullReq'
|