@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.
Files changed (66) 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 +158 -123
  5. package/dist/cf-worker/durable-object.js.map +1 -1
  6. package/dist/cf-worker/index.d.ts +1 -6
  7. package/dist/cf-worker/index.d.ts.map +1 -1
  8. package/dist/cf-worker/index.js +30 -64
  9. package/dist/cf-worker/index.js.map +1 -1
  10. package/dist/cf-worker/make-worker.d.ts +6 -0
  11. package/dist/cf-worker/make-worker.d.ts.map +1 -0
  12. package/dist/cf-worker/make-worker.js +31 -0
  13. package/dist/cf-worker/make-worker.js.map +1 -0
  14. package/dist/cf-worker/mod.d.ts +3 -0
  15. package/dist/cf-worker/mod.d.ts.map +1 -0
  16. package/dist/cf-worker/mod.js +3 -0
  17. package/dist/cf-worker/mod.js.map +1 -0
  18. package/dist/cf-worker/types.d.ts +2 -0
  19. package/dist/cf-worker/types.d.ts.map +1 -0
  20. package/dist/cf-worker/types.js +2 -0
  21. package/dist/cf-worker/types.js.map +1 -0
  22. package/dist/cf-worker/worker.d.ts +6 -0
  23. package/dist/cf-worker/worker.d.ts.map +1 -0
  24. package/dist/cf-worker/worker.js +29 -0
  25. package/dist/cf-worker/worker.js.map +1 -0
  26. package/dist/common/mod.d.ts +2 -0
  27. package/dist/common/mod.d.ts.map +1 -0
  28. package/dist/common/mod.js +2 -0
  29. package/dist/common/mod.js.map +1 -0
  30. package/dist/common/ws-message-types.d.ts +92 -216
  31. package/dist/common/ws-message-types.d.ts.map +1 -1
  32. package/dist/common/ws-message-types.js +13 -9
  33. package/dist/common/ws-message-types.js.map +1 -1
  34. package/dist/sync-impl/mod.d.ts +2 -0
  35. package/dist/sync-impl/mod.d.ts.map +1 -0
  36. package/dist/sync-impl/mod.js +2 -0
  37. package/dist/sync-impl/mod.js.map +1 -0
  38. package/dist/sync-impl/ws-impl.d.ts +5 -13
  39. package/dist/sync-impl/ws-impl.d.ts.map +1 -1
  40. package/dist/sync-impl/ws-impl.js +69 -69
  41. package/dist/sync-impl/ws-impl.js.map +1 -1
  42. package/package.json +17 -8
  43. package/src/cf-worker/durable-object.ts +233 -153
  44. package/src/cf-worker/mod.ts +2 -0
  45. package/src/cf-worker/worker.ts +39 -0
  46. package/src/common/ws-message-types.ts +18 -9
  47. package/src/sync-impl/ws-impl.ts +120 -121
  48. package/.netlify/state.json +0 -3
  49. package/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/8ee300993f9a2c909aede59646369fa0cc28d25304b9b9af94f54d4543ad60f4.sqlite +0 -0
  50. package/.wrangler/state/v3/do/websocket-server-WebSocketServer/2c3676d859102e448b271e1b4336b1e40778992b14728e2a2bd642c0f225e9c5.sqlite +0 -0
  51. package/.wrangler/state/v3/do/websocket-server-WebSocketServer/6eb1111cecdc6ae7e3a38c3d7db0f3ec7de5b318e682cdcf0ff0dc881cbbcee7.sqlite +0 -0
  52. package/.wrangler/state/v3/do/websocket-server-WebSocketServer/c4aa0e61dcd784604bee52282348e0538ab69ec76e1652322bbccd539d9ff3ee.sqlite +0 -0
  53. package/.wrangler/state/v3/do/websocket-server-WebSocketServer/d87433156774c933da83e962eba16107cf02ade2f133d7205963558fe47db3ff.sqlite +0 -0
  54. package/.wrangler/tmp/dev-33iU4b/index.js +0 -42167
  55. package/.wrangler/tmp/dev-33iU4b/index.js.map +0 -8
  56. package/.wrangler/tmp/dev-9rcIR8/index.js +0 -18887
  57. package/.wrangler/tmp/dev-9rcIR8/index.js.map +0 -8
  58. package/.wrangler/tmp/dev-rI63Kk/index.js +0 -42165
  59. package/.wrangler/tmp/dev-rI63Kk/index.js.map +0 -8
  60. package/.wrangler/tmp/dev-txPodK/index.js +0 -42118
  61. package/.wrangler/tmp/dev-txPodK/index.js.map +0 -8
  62. package/src/cf-worker/index.ts +0 -84
  63. package/tsconfig.json +0 -12
  64. package/wrangler.toml +0 -21
  65. /package/src/common/{index.ts → mod.ts} +0 -0
  66. /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,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
- 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
+ /**
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
- constructor(ctx: DurableObjectState, env: Env) {
34
- super(ctx, env)
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
- fetch = async (_request: Request) =>
38
- Effect.gen(this, function* () {
39
- const { 0: client, 1: server } = new WebSocketPair()
44
+ export type MakeDurableObjectClass = (options?: MakeDurableObjectClassOptions) => {
45
+ new (ctx: DurableObjectState, env: Env): DurableObject<Env>
46
+ }
40
47
 
41
- // See https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server
48
+ export const makeDurableObject: MakeDurableObjectClass = (options) => {
49
+ return class WebSocketServerBase extends DurableObject<Env> {
50
+ storage: SyncStorage | undefined = undefined
42
51
 
43
- this.ctx.acceptWebSocket(server)
52
+ constructor(ctx: DurableObjectState, env: Env) {
53
+ super(ctx, env)
54
+ }
44
55
 
45
- this.ctx.setWebSocketAutoResponse(
46
- new WebSocketRequestResponsePair(
47
- encodeIncomingMessage(WSMessage.Ping.make({ requestId: 'ping' })),
48
- encodeOutgoingMessage(WSMessage.Pong.make({ requestId: 'ping' })),
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
- const colSpec = makeColumnSpec(mutationLogTable.sqliteDef.ast)
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
- return new Response(null, {
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
- webSocketMessage = async (ws: WebSocketClient, message: ArrayBuffer | string) => {
62
- const decodedMessageRes = decodeIncomingMessage(message)
68
+ this.ctx.acceptWebSocket(server)
63
69
 
64
- if (decodedMessageRes._tag === 'Left') {
65
- console.error('Invalid message received', decodedMessageRes.left)
66
- return
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
- const decodedMessage = decodedMessageRes.right
70
- const requestId = decodedMessage.requestId
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
- try {
73
- switch (decodedMessage._tag) {
74
- case 'WSMessage.PullReq': {
75
- const cursor = decodedMessage.cursor
76
- const CHUNK_SIZE = 100
80
+ return new Response(null, {
81
+ status: 101,
82
+ webSocket: client,
83
+ })
84
+ }).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
77
85
 
78
- // TODO use streaming
79
- const remainingEvents = [...(await this.storage.getEvents(cursor))]
86
+ webSocketMessage = (ws: WebSocketClient, message: ArrayBuffer | string) =>
87
+ Effect.gen(this, function* () {
88
+ const decodedMessageRes = decodeIncomingMessage(message)
80
89
 
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
90
+ if (decodedMessageRes._tag === 'Left') {
91
+ console.error('Invalid message received', decodedMessageRes.left)
92
+ return
93
+ }
86
94
 
87
- ws.send(encodeOutgoingMessage(WSMessage.PullRes.make({ events: encodedEvents, hasMore, requestId })))
95
+ const decodedMessage = decodedMessageRes.right
96
+ const requestId = decodedMessage.requestId
88
97
 
89
- if (hasMore === false) {
90
- break
91
- }
92
- }
98
+ const storage = this.storage
93
99
 
94
- break
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
- // TODO handle clientId unique conflict
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
- // 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()
111
+ const cursor = decodedMessage.cursor
112
+ const CHUNK_SIZE = 100
119
113
 
120
- ws.send(
121
- encodeOutgoingMessage(
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
- // console.debug(`Broadcasting mutation event to ${this.subscribedWebSockets.size} clients`)
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
- const connectedClients = this.ctx.getWebSockets()
121
+ ws.send(
122
+ encodeOutgoingMessage(
123
+ WSMessage.PullRes.make({ events, remaining: remainingEvents.length, requestId }),
124
+ ),
125
+ )
129
126
 
130
- if (connectedClients.length > 0) {
131
- const broadcastMessage = encodeOutgoingMessage(
132
- WSMessage.PushBroadcast.make({
133
- mutationEventEncoded: decodedMessage.mutationEventEncoded,
134
- persisted: decodedMessage.persisted,
135
- }),
136
- )
127
+ if (remainingEvents.length === 0) {
128
+ break
129
+ }
130
+ }
137
131
 
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
- // }
132
+ break
143
133
  }
144
- }
134
+ case 'WSMessage.PushReq': {
135
+ if (options?.onPush) {
136
+ yield* Effect.tryAll(() => options.onPush!(decodedMessage))
137
+ }
145
138
 
146
- await storePromise
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
- 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
- }
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
- await this.storage.resetRoom()
157
- ws.send(encodeOutgoingMessage(WSMessage.AdminResetRoomRes.make({ requestId })))
151
+ yield* Effect.fail(err).pipe(Effect.ignoreLogged)
158
152
 
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
- }
153
+ ws.send(encodeOutgoingMessage(err))
154
+ return
155
+ }
166
156
 
167
- ws.send(
168
- encodeOutgoingMessage(
169
- WSMessage.AdminInfoRes.make({ requestId, info: { durableObjectId: this.ctx.id.toString() } }),
170
- ),
171
- )
157
+ // TODO handle clientId unique conflict
172
158
 
173
- break
174
- }
175
- default: {
176
- console.error('unsupported message', decodedMessage)
177
- return shouldNeverHappen()
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
- } catch (error: any) {
181
- ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: error.message, requestId })))
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
- 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
- }
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.Any | undefined> => {
193
- const rawEvents = await env.DB.prepare(`SELECT * FROM ${dbName} ORDER BY idGlobal DESC LIMIT 1`).all()
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).map((e) => ({
198
- ...e,
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 (cursor: number | undefined): Promise<ReadonlyArray<MutationEvent.Any>> => {
206
- const whereClause = cursor ? `WHERE idGlobal > ${cursor}` : ''
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(`SELECT * FROM ${dbName} ${whereClause} ORDER BY idGlobal ASC`).all()
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((e) => ({
213
- ...e,
214
- id: { global: e.idGlobal, local: 0 },
215
- parentId: { global: e.parentIdGlobal, local: 0 },
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.Any) => {
221
- const sql = `INSERT INTO ${dbName} (idGlobal, parentIdGlobal, args, mutation) VALUES (?, ?, ?, ?)`
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.global, event.parentId.global, JSON.stringify(event.args), event.mutation)
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,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