@livestore/sync-cf 0.2.0 → 0.3.0-dev.10
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 +158 -123
- 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 +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 +233 -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/.netlify/state.json +0 -3
- package/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/8ee300993f9a2c909aede59646369fa0cc28d25304b9b9af94f54d4543ad60f4.sqlite +0 -0
- package/.wrangler/state/v3/do/websocket-server-WebSocketServer/2c3676d859102e448b271e1b4336b1e40778992b14728e2a2bd642c0f225e9c5.sqlite +0 -0
- package/.wrangler/state/v3/do/websocket-server-WebSocketServer/6eb1111cecdc6ae7e3a38c3d7db0f3ec7de5b318e682cdcf0ff0dc881cbbcee7.sqlite +0 -0
- package/.wrangler/state/v3/do/websocket-server-WebSocketServer/c4aa0e61dcd784604bee52282348e0538ab69ec76e1652322bbccd539d9ff3ee.sqlite +0 -0
- package/.wrangler/state/v3/do/websocket-server-WebSocketServer/d87433156774c933da83e962eba16107cf02ade2f133d7205963558fe47db3ff.sqlite +0 -0
- package/.wrangler/tmp/dev-33iU4b/index.js +0 -42167
- package/.wrangler/tmp/dev-33iU4b/index.js.map +0 -8
- package/.wrangler/tmp/dev-9rcIR8/index.js +0 -18887
- package/.wrangler/tmp/dev-9rcIR8/index.js.map +0 -8
- package/.wrangler/tmp/dev-rI63Kk/index.js +0 -42165
- package/.wrangler/tmp/dev-rI63Kk/index.js.map +0 -8
- package/.wrangler/tmp/dev-txPodK/index.js +0 -42118
- package/.wrangler/tmp/dev-txPodK/index.js.map +0 -8
- package/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,276 @@ 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
|
+
/**
|
|
33
|
+
* Needs to be bumped when the storage format changes (e.g. mutationLogTable schema changes)
|
|
34
|
+
*
|
|
35
|
+
* Changing this version number will lead to a "soft reset".
|
|
36
|
+
*/
|
|
37
|
+
export const PERSISTENCE_FORMAT_VERSION = 2
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
export type MakeDurableObjectClassOptions = {
|
|
40
|
+
onPush?: (message: WSMessage.PushReq) => Effect.Effect<void> | Promise<void>
|
|
41
|
+
onPull?: (message: WSMessage.PullReq) => Effect.Effect<void> | Promise<void>
|
|
42
|
+
}
|
|
36
43
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
export type MakeDurableObjectClass = (options?: MakeDurableObjectClassOptions) => {
|
|
45
|
+
new (ctx: DurableObjectState, env: Env): DurableObject<Env>
|
|
46
|
+
}
|
|
40
47
|
|
|
41
|
-
|
|
48
|
+
export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
49
|
+
return class WebSocketServerBase extends DurableObject<Env> {
|
|
50
|
+
storage: SyncStorage | undefined = undefined
|
|
42
51
|
|
|
43
|
-
|
|
52
|
+
constructor(ctx: DurableObjectState, env: Env) {
|
|
53
|
+
super(ctx, env)
|
|
54
|
+
}
|
|
44
55
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
}
|
|
51
63
|
|
|
52
|
-
|
|
53
|
-
this.env.DB.exec(`CREATE TABLE IF NOT EXISTS ${this.dbName} (${colSpec}) strict`)
|
|
64
|
+
const { 0: client, 1: server } = new WebSocketPair()
|
|
54
65
|
|
|
55
|
-
|
|
56
|
-
status: 101,
|
|
57
|
-
webSocket: client,
|
|
58
|
-
})
|
|
59
|
-
}).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
|
|
66
|
+
// See https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server
|
|
60
67
|
|
|
61
|
-
|
|
62
|
-
const decodedMessageRes = decodeIncomingMessage(message)
|
|
68
|
+
this.ctx.acceptWebSocket(server)
|
|
63
69
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
this.ctx.setWebSocketAutoResponse(
|
|
71
|
+
new WebSocketRequestResponsePair(
|
|
72
|
+
encodeIncomingMessage(WSMessage.Ping.make({ requestId: 'ping' })),
|
|
73
|
+
encodeOutgoingMessage(WSMessage.Pong.make({ requestId: 'ping' })),
|
|
74
|
+
),
|
|
75
|
+
)
|
|
68
76
|
|
|
69
|
-
|
|
70
|
-
|
|
77
|
+
const colSpec = makeColumnSpec(mutationLogTable.sqliteDef.ast)
|
|
78
|
+
this.env.DB.exec(`CREATE TABLE IF NOT EXISTS ${this.storage.dbName} (${colSpec}) strict`)
|
|
71
79
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
return new Response(null, {
|
|
81
|
+
status: 101,
|
|
82
|
+
webSocket: client,
|
|
83
|
+
})
|
|
84
|
+
}).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
|
|
77
85
|
|
|
78
|
-
|
|
79
|
-
|
|
86
|
+
webSocketMessage = (ws: WebSocketClient, message: ArrayBuffer | string) =>
|
|
87
|
+
Effect.gen(this, function* () {
|
|
88
|
+
const decodedMessageRes = decodeIncomingMessage(message)
|
|
80
89
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const hasMore = remainingEvents.length > 0
|
|
90
|
+
if (decodedMessageRes._tag === 'Left') {
|
|
91
|
+
console.error('Invalid message received', decodedMessageRes.left)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
86
94
|
|
|
87
|
-
|
|
95
|
+
const decodedMessage = decodedMessageRes.right
|
|
96
|
+
const requestId = decodedMessage.requestId
|
|
88
97
|
|
|
89
|
-
|
|
90
|
-
break
|
|
91
|
-
}
|
|
92
|
-
}
|
|
98
|
+
const storage = this.storage
|
|
93
99
|
|
|
94
|
-
|
|
100
|
+
if (storage === undefined) {
|
|
101
|
+
throw new Error('storage not initialized')
|
|
95
102
|
}
|
|
96
|
-
case 'WSMessage.PushReq': {
|
|
97
|
-
// TODO check whether we could use the Durable Object storage for this to speed up the lookup
|
|
98
|
-
const latestEvent = await this.storage.getLatestEvent()
|
|
99
|
-
const expectedParentId = latestEvent?.id ?? ROOT_ID
|
|
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
|
-
}
|
|
112
103
|
|
|
113
|
-
|
|
104
|
+
try {
|
|
105
|
+
switch (decodedMessage._tag) {
|
|
106
|
+
case 'WSMessage.PullReq': {
|
|
107
|
+
if (options?.onPull) {
|
|
108
|
+
yield* Effect.tryAll(() => options.onPull!(decodedMessage))
|
|
109
|
+
}
|
|
114
110
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
? this.storage.appendEvent(decodedMessage.mutationEventEncoded)
|
|
118
|
-
: Promise.resolve()
|
|
111
|
+
const cursor = decodedMessage.cursor
|
|
112
|
+
const CHUNK_SIZE = 100
|
|
119
113
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
WSMessage.PushAck.make({ mutationId: decodedMessage.mutationEventEncoded.id.global, requestId }),
|
|
123
|
-
),
|
|
124
|
-
)
|
|
114
|
+
// TODO use streaming
|
|
115
|
+
const remainingEvents = [...(yield* Effect.promise(() => storage.getEvents(cursor)))]
|
|
125
116
|
|
|
126
|
-
|
|
117
|
+
// NOTE we want to make sure the WS server responds at least once with `InitRes` even if `events` is empty
|
|
118
|
+
while (true) {
|
|
119
|
+
const events = remainingEvents.splice(0, CHUNK_SIZE)
|
|
127
120
|
|
|
128
|
-
|
|
121
|
+
ws.send(
|
|
122
|
+
encodeOutgoingMessage(
|
|
123
|
+
WSMessage.PullRes.make({ events, remaining: remainingEvents.length, requestId }),
|
|
124
|
+
),
|
|
125
|
+
)
|
|
129
126
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
persisted: decodedMessage.persisted,
|
|
135
|
-
}),
|
|
136
|
-
)
|
|
127
|
+
if (remainingEvents.length === 0) {
|
|
128
|
+
break
|
|
129
|
+
}
|
|
130
|
+
}
|
|
137
131
|
|
|
138
|
-
|
|
139
|
-
console.log('Broadcasting to client', conn === ws ? 'self' : 'other')
|
|
140
|
-
// if (conn !== ws) {
|
|
141
|
-
conn.send(broadcastMessage)
|
|
142
|
-
// }
|
|
132
|
+
break
|
|
143
133
|
}
|
|
144
|
-
|
|
134
|
+
case 'WSMessage.PushReq': {
|
|
135
|
+
if (options?.onPush) {
|
|
136
|
+
yield* Effect.tryAll(() => options.onPush!(decodedMessage))
|
|
137
|
+
}
|
|
145
138
|
|
|
146
|
-
|
|
139
|
+
// TODO check whether we could use the Durable Object storage for this to speed up the lookup
|
|
140
|
+
const latestEvent = yield* Effect.promise(() => storage.getLatestEvent())
|
|
141
|
+
const expectedParentId = latestEvent?.id ?? EventId.ROOT.global
|
|
147
142
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
143
|
+
let i = 0
|
|
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
|
+
})
|
|
155
150
|
|
|
156
|
-
|
|
157
|
-
ws.send(encodeOutgoingMessage(WSMessage.AdminResetRoomRes.make({ requestId })))
|
|
151
|
+
yield* Effect.fail(err).pipe(Effect.ignoreLogged)
|
|
158
152
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (decodedMessage.adminSecret !== this.env.ADMIN_SECRET) {
|
|
163
|
-
ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: 'Invalid admin secret', requestId })))
|
|
164
|
-
return
|
|
165
|
-
}
|
|
153
|
+
ws.send(encodeOutgoingMessage(err))
|
|
154
|
+
return
|
|
155
|
+
}
|
|
166
156
|
|
|
167
|
-
|
|
168
|
-
encodeOutgoingMessage(
|
|
169
|
-
WSMessage.AdminInfoRes.make({ requestId, info: { durableObjectId: this.ctx.id.toString() } }),
|
|
170
|
-
),
|
|
171
|
-
)
|
|
157
|
+
// TODO handle clientId unique conflict
|
|
172
158
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
159
|
+
const createdAt = new Date().toISOString()
|
|
160
|
+
|
|
161
|
+
// NOTE we're currently not blocking on this to allow broadcasting right away
|
|
162
|
+
const storePromise = storage.appendEvent(mutationEventEncoded, createdAt)
|
|
163
|
+
|
|
164
|
+
ws.send(
|
|
165
|
+
encodeOutgoingMessage(WSMessage.PushAck.make({ mutationId: mutationEventEncoded.id, requestId })),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
// console.debug(`Broadcasting mutation event to ${this.subscribedWebSockets.size} clients`)
|
|
169
|
+
|
|
170
|
+
const connectedClients = this.ctx.getWebSockets()
|
|
171
|
+
|
|
172
|
+
if (connectedClients.length > 0) {
|
|
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
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
yield* Effect.promise(() => storePromise)
|
|
190
|
+
|
|
191
|
+
i++
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
break
|
|
195
|
+
}
|
|
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
|
+
|
|
202
|
+
yield* Effect.promise(() => storage.resetRoom())
|
|
203
|
+
ws.send(encodeOutgoingMessage(WSMessage.AdminResetRoomRes.make({ requestId })))
|
|
204
|
+
|
|
205
|
+
break
|
|
206
|
+
}
|
|
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
|
+
|
|
213
|
+
ws.send(
|
|
214
|
+
encodeOutgoingMessage(
|
|
215
|
+
WSMessage.AdminInfoRes.make({ requestId, info: { durableObjectId: this.ctx.id.toString() } }),
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
break
|
|
220
|
+
}
|
|
221
|
+
default: {
|
|
222
|
+
console.error('unsupported message', decodedMessage)
|
|
223
|
+
return shouldNeverHappen()
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} catch (error: any) {
|
|
227
|
+
ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: error.message, requestId })))
|
|
178
228
|
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
|
|
229
|
+
}).pipe(
|
|
230
|
+
Effect.withSpan('@livestore/sync-cf:durable-object:webSocketMessage'),
|
|
231
|
+
Effect.tapCauseLogPretty,
|
|
232
|
+
Logger.withMinimumLogLevel(LogLevel.Debug),
|
|
233
|
+
Effect.provide(Logger.pretty),
|
|
234
|
+
Effect.runPromise,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
webSocketClose = async (ws: WebSocketClient, code: number, _reason: string, _wasClean: boolean) => {
|
|
238
|
+
// If the client closes the connection, the runtime will invoke the webSocketClose() handler.
|
|
239
|
+
ws.close(code, 'Durable Object is closing WebSocket')
|
|
182
240
|
}
|
|
183
241
|
}
|
|
242
|
+
}
|
|
184
243
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
244
|
+
type SyncStorage = {
|
|
245
|
+
dbName: string
|
|
246
|
+
getLatestEvent: () => Promise<MutationEvent.AnyEncodedGlobal | undefined>
|
|
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>
|
|
189
254
|
}
|
|
190
255
|
|
|
191
|
-
const makeStorage = (ctx: DurableObjectState, env: Env, dbName: string) => {
|
|
192
|
-
const getLatestEvent = async (): Promise<MutationEvent.
|
|
193
|
-
const rawEvents = await env.DB.prepare(`SELECT * FROM ${dbName} ORDER BY
|
|
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()
|
|
194
259
|
if (rawEvents.error) {
|
|
195
260
|
throw new Error(rawEvents.error)
|
|
196
261
|
}
|
|
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
|
-
}))
|
|
262
|
+
const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results)
|
|
263
|
+
|
|
202
264
|
return events[0]
|
|
203
265
|
}
|
|
204
266
|
|
|
205
|
-
const getEvents = async (
|
|
206
|
-
|
|
267
|
+
const getEvents = async (
|
|
268
|
+
cursor: number | undefined,
|
|
269
|
+
): Promise<
|
|
270
|
+
ReadonlyArray<{ mutationEventEncoded: MutationEvent.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> }>
|
|
271
|
+
> => {
|
|
272
|
+
const whereClause = cursor === undefined ? '' : `WHERE id > ${cursor}`
|
|
273
|
+
const sql = `SELECT * FROM ${dbName} ${whereClause} ORDER BY id ASC`
|
|
207
274
|
// TODO handle case where `cursor` was not found
|
|
208
|
-
const rawEvents = await env.DB.prepare(
|
|
275
|
+
const rawEvents = await env.DB.prepare(sql).all()
|
|
209
276
|
if (rawEvents.error) {
|
|
210
277
|
throw new Error(rawEvents.error)
|
|
211
278
|
}
|
|
212
|
-
const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results).map(
|
|
213
|
-
...
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
279
|
+
const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results).map(
|
|
280
|
+
({ createdAt, ...mutationEventEncoded }) => ({
|
|
281
|
+
mutationEventEncoded,
|
|
282
|
+
metadata: Option.some({ createdAt }),
|
|
283
|
+
}),
|
|
284
|
+
)
|
|
217
285
|
return events
|
|
218
286
|
}
|
|
219
287
|
|
|
220
|
-
const appendEvent = async (event: MutationEvent.
|
|
221
|
-
const sql = `INSERT INTO ${dbName} (
|
|
288
|
+
const appendEvent = async (event: MutationEvent.AnyEncodedGlobal, createdAt: string) => {
|
|
289
|
+
const sql = `INSERT INTO ${dbName} (id, parentId, args, mutation, createdAt) VALUES (?, ?, ?, ?, ?)`
|
|
222
290
|
await env.DB.prepare(sql)
|
|
223
|
-
.bind(event.id
|
|
291
|
+
.bind(event.id, event.parentId, JSON.stringify(event.args), event.mutation, createdAt)
|
|
224
292
|
.run()
|
|
225
293
|
}
|
|
226
294
|
|
|
@@ -228,5 +296,17 @@ const makeStorage = (ctx: DurableObjectState, env: Env, dbName: string) => {
|
|
|
228
296
|
await ctx.storage.deleteAll()
|
|
229
297
|
}
|
|
230
298
|
|
|
231
|
-
return { getLatestEvent, getEvents, appendEvent, resetRoom }
|
|
299
|
+
return { dbName, getLatestEvent, getEvents, appendEvent, resetRoom }
|
|
300
|
+
}
|
|
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
|
|
232
310
|
}
|
|
311
|
+
|
|
312
|
+
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
|