@livestore/sync-cf 0.0.0-snapshot-4c2ee347c9a2ee93b7d67a7652d1db36f5b7ee30 → 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.
Files changed (50) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/cf-worker/durable-object.d.ts +40 -34
  3. package/dist/cf-worker/durable-object.d.ts.map +1 -1
  4. package/dist/cf-worker/durable-object.js +159 -124
  5. package/dist/cf-worker/durable-object.js.map +1 -1
  6. package/dist/cf-worker/mod.d.ts +3 -0
  7. package/dist/cf-worker/mod.d.ts.map +1 -0
  8. package/dist/cf-worker/mod.js +3 -0
  9. package/dist/cf-worker/mod.js.map +1 -0
  10. package/dist/cf-worker/worker.d.ts +6 -0
  11. package/dist/cf-worker/worker.d.ts.map +1 -0
  12. package/dist/cf-worker/worker.js +29 -0
  13. package/dist/cf-worker/worker.js.map +1 -0
  14. package/dist/common/{index.js → mod.d.ts} +1 -1
  15. package/dist/common/mod.d.ts.map +1 -0
  16. package/dist/common/{index.d.ts → mod.js} +1 -1
  17. package/dist/common/mod.js.map +1 -0
  18. package/dist/common/ws-message-types.d.ts +92 -216
  19. package/dist/common/ws-message-types.d.ts.map +1 -1
  20. package/dist/common/ws-message-types.js +13 -9
  21. package/dist/common/ws-message-types.js.map +1 -1
  22. package/dist/sync-impl/mod.d.ts +2 -0
  23. package/dist/sync-impl/mod.d.ts.map +1 -0
  24. package/dist/sync-impl/mod.js +2 -0
  25. package/dist/sync-impl/mod.js.map +1 -0
  26. package/dist/sync-impl/ws-impl.d.ts +5 -13
  27. package/dist/sync-impl/ws-impl.d.ts.map +1 -1
  28. package/dist/sync-impl/ws-impl.js +69 -69
  29. package/dist/sync-impl/ws-impl.js.map +1 -1
  30. package/package.json +17 -8
  31. package/src/cf-worker/durable-object.ts +236 -153
  32. package/src/cf-worker/mod.ts +2 -0
  33. package/src/cf-worker/worker.ts +39 -0
  34. package/src/common/ws-message-types.ts +18 -9
  35. package/src/sync-impl/ws-impl.ts +120 -121
  36. package/dist/cf-worker/index.d.ts +0 -8
  37. package/dist/cf-worker/index.d.ts.map +0 -1
  38. package/dist/cf-worker/index.js +0 -67
  39. package/dist/cf-worker/index.js.map +0 -1
  40. package/dist/common/index.d.ts.map +0 -1
  41. package/dist/common/index.js.map +0 -1
  42. package/dist/sync-impl/index.d.ts +0 -2
  43. package/dist/sync-impl/index.d.ts.map +0 -1
  44. package/dist/sync-impl/index.js +0 -2
  45. package/dist/sync-impl/index.js.map +0 -1
  46. package/src/cf-worker/index.ts +0 -84
  47. package/tsconfig.json +0 -12
  48. package/wrangler.toml +0 -21
  49. /package/src/common/{index.ts → mod.ts} +0 -0
  50. /package/src/sync-impl/{index.ts → mod.ts} +0 -0
@@ -1,13 +1,14 @@
1
- import { makeColumnSpec, ROOT_ID } from '@livestore/common'
2
- import { DbSchema, type MutationEvent, mutationEventSchemaAny } from '@livestore/common/schema'
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/index.js'
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<WebSocketServer>
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
- idGlobal: DbSchema.integer({ primaryKey: true }),
23
- parentIdGlobal: DbSchema.integer({}),
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
- // Durable Object
29
- export class WebSocketServer extends DurableObject<Env> {
30
- dbName = `mutation_log_${this.ctx.id.toString()}`
31
- storage = makeStorage(this.ctx, this.env, this.dbName)
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
- constructor(ctx: DurableObjectState, env: Env) {
34
- super(ctx, env)
35
- }
50
+ export type MakeDurableObjectClass = (options?: MakeDurableObjectClassOptions) => {
51
+ new (ctx: DurableObjectState, env: Env): DurableObject<Env>
52
+ }
36
53
 
37
- fetch = async (_request: Request) =>
38
- Effect.gen(this, function* () {
39
- const { 0: client, 1: server } = new WebSocketPair()
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
- // See https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server
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
- this.ctx.acceptWebSocket(server)
65
+ const { 0: client, 1: server } = new WebSocketPair()
44
66
 
45
- this.ctx.setWebSocketAutoResponse(
46
- new WebSocketRequestResponsePair(
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
- const colSpec = makeColumnSpec(mutationLogTable.sqliteDef.ast)
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
- return new Response(null, {
56
- status: 101,
57
- webSocket: client,
58
- })
59
- }).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
72
+ this.ctx.acceptWebSocket(server)
60
73
 
61
- webSocketMessage = async (ws: WebSocketClient, message: ArrayBuffer | string) => {
62
- const decodedMessageRes = decodeIncomingMessage(message)
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
- if (decodedMessageRes._tag === 'Left') {
65
- console.error('Invalid message received', decodedMessageRes.left)
66
- return
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
- const decodedMessage = decodedMessageRes.right
70
- const requestId = decodedMessage.requestId
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
- try {
73
- switch (decodedMessage._tag) {
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
- // TODO use streaming
79
- const remainingEvents = [...(await this.storage.getEvents(cursor))]
115
+ // TODO use streaming
116
+ const remainingEvents = [...(yield* Effect.promise(() => storage.getEvents(cursor)))]
80
117
 
81
- // NOTE we want to make sure the WS server responds at least once with `InitRes` even if `events` is empty
82
- while (true) {
83
- const events = remainingEvents.splice(0, CHUNK_SIZE)
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
- ws.send(encodeOutgoingMessage(WSMessage.PullRes.make({ events: encodedEvents, hasMore, requestId })))
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
- break
95
- }
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
- }
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
- // TODO handle clientId unique conflict
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
- // NOTE we're currently not blocking on this to allow broadcasting right away
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
- ws.send(
121
- encodeOutgoingMessage(
122
- WSMessage.PushAck.make({ mutationId: decodedMessage.mutationEventEncoded.id.global, requestId }),
123
- ),
124
- )
154
+ ws.send(encodeOutgoingMessage(err))
155
+ return
156
+ }
125
157
 
126
- // console.debug(`Broadcasting mutation event to ${this.subscribedWebSockets.size} clients`)
158
+ // TODO handle clientId unique conflict
127
159
 
128
- const connectedClients = this.ctx.getWebSockets()
160
+ const createdAt = new Date().toISOString()
129
161
 
130
- if (connectedClients.length > 0) {
131
- const broadcastMessage = encodeOutgoingMessage(
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
- for (const conn of connectedClients) {
139
- console.log('Broadcasting to client', conn === ws ? 'self' : 'other')
140
- // if (conn !== ws) {
141
- conn.send(broadcastMessage)
142
- // }
143
- }
144
- }
165
+ ws.send(
166
+ encodeOutgoingMessage(WSMessage.PushAck.make({ mutationId: mutationEventEncoded.id, requestId })),
167
+ )
145
168
 
146
- await storePromise
169
+ // console.debug(`Broadcasting mutation event to ${this.subscribedWebSockets.size} clients`)
147
170
 
148
- break
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
- await this.storage.resetRoom()
157
- ws.send(encodeOutgoingMessage(WSMessage.AdminResetRoomRes.make({ requestId })))
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
- break
160
- }
161
- case 'WSMessage.AdminInfoReq': {
162
- if (decodedMessage.adminSecret !== this.env.ADMIN_SECRET) {
163
- ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: 'Invalid admin secret', requestId })))
164
- return
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
- ws.send(
168
- encodeOutgoingMessage(
169
- WSMessage.AdminInfoRes.make({ requestId, info: { durableObjectId: this.ctx.id.toString() } }),
170
- ),
171
- )
190
+ yield* Effect.promise(() => storePromise)
172
191
 
173
- break
174
- }
175
- default: {
176
- console.error('unsupported message', decodedMessage)
177
- return shouldNeverHappen()
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
- } catch (error: any) {
181
- ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: error.message, requestId })))
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
- webSocketClose = async (ws: WebSocketClient, code: number, _reason: string, _wasClean: boolean) => {
186
- // If the client closes the connection, the runtime will invoke the webSocketClose() handler.
187
- ws.close(code, 'Durable Object is closing WebSocket')
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, dbName: string) => {
192
- const getLatestEvent = async (): Promise<MutationEvent.Any | undefined> => {
193
- const rawEvents = await env.DB.prepare(`SELECT * FROM ${dbName} ORDER BY idGlobal DESC LIMIT 1`).all()
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).map((e) => ({
198
- ...e,
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 (cursor: number | undefined): Promise<ReadonlyArray<MutationEvent.Any>> => {
206
- const whereClause = cursor ? `WHERE idGlobal > ${cursor}` : ''
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(`SELECT * FROM ${dbName} ${whereClause} ORDER BY idGlobal ASC`).all()
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((e) => ({
213
- ...e,
214
- id: { global: e.idGlobal, local: 0 },
215
- parentId: { global: e.parentIdGlobal, local: 0 },
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.Any) => {
221
- const sql = `INSERT INTO ${dbName} (idGlobal, parentIdGlobal, args, mutation) VALUES (?, ?, ?, ?)`
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.global, event.parentId.global, JSON.stringify(event.args), event.mutation)
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,2 @@
1
+ export * from './durable-object.js'
2
+ export * from './worker.js'
@@ -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 { mutationEventSchemaEncodedAny } from '@livestore/common/schema'
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
- // /** The */
15
- // cursor: Schema.String,
16
- events: Schema.Array(mutationEventSchemaEncodedAny),
17
- hasMore: Schema.Boolean,
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: mutationEventSchemaEncodedAny,
24
- persisted: Schema.Boolean,
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
- mutationEventEncoded: mutationEventSchemaEncodedAny,
32
- persisted: Schema.Boolean,
41
+ batch: Schema.Array(MutationEvent.AnyEncodedGlobal),
33
42
  })
34
43
 
35
44
  export type PushReq = typeof PushReq.Type