@livestore/sync-cf 0.3.0-dev.4 → 0.3.0-dev.40

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 (51) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/cf-worker/durable-object.d.ts +58 -47
  3. package/dist/cf-worker/durable-object.d.ts.map +1 -1
  4. package/dist/cf-worker/durable-object.js +233 -156
  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 +12 -0
  11. package/dist/cf-worker/worker.d.ts.map +1 -0
  12. package/dist/cf-worker/worker.js +58 -0
  13. package/dist/cf-worker/worker.js.map +1 -0
  14. package/dist/common/mod.d.ts +7 -0
  15. package/dist/common/mod.d.ts.map +1 -0
  16. package/dist/common/mod.js +7 -0
  17. package/dist/common/mod.js.map +1 -0
  18. package/dist/common/ws-message-types.d.ts +163 -266
  19. package/dist/common/ws-message-types.d.ts.map +1 -1
  20. package/dist/common/ws-message-types.js +19 -24
  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 +3 -15
  27. package/dist/sync-impl/ws-impl.d.ts.map +1 -1
  28. package/dist/sync-impl/ws-impl.js +89 -36
  29. package/dist/sync-impl/ws-impl.js.map +1 -1
  30. package/package.json +15 -13
  31. package/src/cf-worker/durable-object.ts +309 -177
  32. package/src/cf-worker/mod.ts +2 -0
  33. package/src/cf-worker/worker.ts +85 -0
  34. package/src/common/mod.ts +8 -0
  35. package/src/common/ws-message-types.ts +22 -36
  36. package/src/sync-impl/ws-impl.ts +146 -100
  37. package/dist/cf-worker/index.d.ts +0 -8
  38. package/dist/cf-worker/index.d.ts.map +0 -1
  39. package/dist/cf-worker/index.js +0 -67
  40. package/dist/cf-worker/index.js.map +0 -1
  41. package/dist/common/index.d.ts +0 -2
  42. package/dist/common/index.d.ts.map +0 -1
  43. package/dist/common/index.js +0 -2
  44. package/dist/common/index.js.map +0 -1
  45. package/dist/sync-impl/index.d.ts +0 -2
  46. package/dist/sync-impl/index.d.ts.map +0 -1
  47. package/dist/sync-impl/index.js +0 -2
  48. package/dist/sync-impl/index.js.map +0 -1
  49. package/src/cf-worker/index.ts +0 -84
  50. package/src/common/index.ts +0 -1
  51. /package/src/sync-impl/{index.ts → mod.ts} +0 -0
@@ -1,14 +1,14 @@
1
- import { makeColumnSpec } from '@livestore/common'
2
- import { DbSchema, EventId, type MutationEvent } from '@livestore/common/schema'
1
+ import { makeColumnSpec, UnexpectedError } from '@livestore/common'
2
+ import { EventId, type LiveStoreEvent, State } from '@livestore/common/schema'
3
3
  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/index.js'
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<WebSocketServer>
11
+ WEBSOCKET_SERVER: DurableObjectNamespace
12
12
  DB: D1Database
13
13
  ADMIN_SECRET: string
14
14
  }
@@ -19,51 +19,87 @@ const encodeOutgoingMessage = Schema.encodeSync(Schema.parseJson(WSMessage.Backe
19
19
  const encodeIncomingMessage = Schema.encodeSync(Schema.parseJson(WSMessage.ClientToBackendMessage))
20
20
  const decodeIncomingMessage = Schema.decodeUnknownEither(Schema.parseJson(WSMessage.ClientToBackendMessage))
21
21
 
22
- // NOTE actual table name is determined at runtime by `WebSocketServer.dbName`
23
- export const mutationLogTable = DbSchema.table('__unused', {
24
- idGlobal: DbSchema.integer({ primaryKey: true }),
25
- parentIdGlobal: DbSchema.integer({}),
26
- mutation: DbSchema.text({}),
27
- args: DbSchema.text({ schema: Schema.parseJson(Schema.Any) }),
28
- /** ISO date format */
29
- createdAt: DbSchema.text({}),
22
+ export const eventlogTable = State.SQLite.table({
23
+ // NOTE actual table name is determined at runtime
24
+ name: 'eventlog_${PERSISTENCE_FORMAT_VERSION}_${storeId}',
25
+ columns: {
26
+ id: State.SQLite.integer({ primaryKey: true, schema: EventId.GlobalEventId }),
27
+ parentId: State.SQLite.integer({ schema: EventId.GlobalEventId }),
28
+ name: State.SQLite.text({}),
29
+ args: State.SQLite.text({ schema: Schema.parseJson(Schema.Any) }),
30
+ /** ISO date format. Currently only used for debugging purposes. */
31
+ createdAt: State.SQLite.text({}),
32
+ clientId: State.SQLite.text({}),
33
+ sessionId: State.SQLite.text({}),
34
+ },
30
35
  })
31
36
 
32
- // Durable Object
33
- export class WebSocketServer extends DurableObject<Env> {
34
- dbName = `mutation_log_${this.ctx.id.toString()}`
35
- storage = makeStorage(this.ctx, this.env, this.dbName)
37
+ const WebSocketAttachmentSchema = Schema.parseJson(
38
+ Schema.Struct({
39
+ storeId: Schema.String,
40
+ }),
41
+ )
42
+
43
+ export const PULL_CHUNK_SIZE = 100
44
+
45
+ /**
46
+ * Needs to be bumped when the storage format changes (e.g. eventlogTable schema changes)
47
+ *
48
+ * Changing this version number will lead to a "soft reset".
49
+ */
50
+ export const PERSISTENCE_FORMAT_VERSION = 5
51
+
52
+ export type MakeDurableObjectClassOptions = {
53
+ onPush?: (message: WSMessage.PushReq) => Effect.Effect<void> | Promise<void>
54
+ onPushRes?: (message: WSMessage.PushAck | WSMessage.Error) => Effect.Effect<void> | Promise<void>
55
+ onPull?: (message: WSMessage.PullReq) => Effect.Effect<void> | Promise<void>
56
+ onPullRes?: (message: WSMessage.PullRes | WSMessage.Error) => Effect.Effect<void> | Promise<void>
57
+ }
36
58
 
37
- constructor(ctx: DurableObjectState, env: Env) {
38
- super(ctx, env)
39
- }
59
+ export type MakeDurableObjectClass = (options?: MakeDurableObjectClassOptions) => {
60
+ new (ctx: DurableObjectState, env: Env): DurableObject<Env>
61
+ }
40
62
 
41
- fetch = async (_request: Request) =>
42
- Effect.gen(this, function* () {
43
- const { 0: client, 1: server } = new WebSocketPair()
63
+ export const makeDurableObject: MakeDurableObjectClass = (options) => {
64
+ return class WebSocketServerBase extends DurableObject<Env> {
65
+ /** Needed to prevent concurrent pushes */
66
+ private pushSemaphore = Effect.makeSemaphore(1).pipe(Effect.runSync)
44
67
 
45
- // See https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server
68
+ constructor(ctx: DurableObjectState, env: Env) {
69
+ super(ctx, env)
70
+ }
46
71
 
47
- this.ctx.acceptWebSocket(server)
72
+ fetch = async (request: Request) =>
73
+ Effect.gen(this, function* () {
74
+ const storeId = getStoreId(request)
75
+ const storage = makeStorage(this.ctx, this.env, storeId)
48
76
 
49
- this.ctx.setWebSocketAutoResponse(
50
- new WebSocketRequestResponsePair(
51
- encodeIncomingMessage(WSMessage.Ping.make({ requestId: 'ping' })),
52
- encodeOutgoingMessage(WSMessage.Pong.make({ requestId: 'ping' })),
53
- ),
54
- )
77
+ const { 0: client, 1: server } = new WebSocketPair()
55
78
 
56
- const colSpec = makeColumnSpec(mutationLogTable.sqliteDef.ast)
57
- this.env.DB.exec(`CREATE TABLE IF NOT EXISTS ${this.dbName} (${colSpec}) strict`)
79
+ // Since we're using websocket hibernation, we need to remember the storeId for subsequent `webSocketMessage` calls
80
+ server.serializeAttachment(Schema.encodeSync(WebSocketAttachmentSchema)({ storeId }))
58
81
 
59
- return new Response(null, {
60
- status: 101,
61
- webSocket: client,
62
- })
63
- }).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
82
+ // See https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server
64
83
 
65
- webSocketMessage = (ws: WebSocketClient, message: ArrayBuffer | string) =>
66
- Effect.gen(this, function* () {
84
+ this.ctx.acceptWebSocket(server)
85
+
86
+ this.ctx.setWebSocketAutoResponse(
87
+ new WebSocketRequestResponsePair(
88
+ encodeIncomingMessage(WSMessage.Ping.make({ requestId: 'ping' })),
89
+ encodeOutgoingMessage(WSMessage.Pong.make({ requestId: 'ping' })),
90
+ ),
91
+ )
92
+
93
+ const colSpec = makeColumnSpec(eventlogTable.sqliteDef.ast)
94
+ this.env.DB.exec(`CREATE TABLE IF NOT EXISTS ${storage.dbName} (${colSpec}) strict`)
95
+
96
+ return new Response(null, {
97
+ status: 101,
98
+ webSocket: client,
99
+ })
100
+ }).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
101
+
102
+ webSocketMessage = (ws: WebSocketClient, message: ArrayBuffer | string) => {
67
103
  const decodedMessageRes = decodeIncomingMessage(message)
68
104
 
69
105
  if (decodedMessageRes._tag === 'Left') {
@@ -74,188 +110,284 @@ export class WebSocketServer extends DurableObject<Env> {
74
110
  const decodedMessage = decodedMessageRes.right
75
111
  const requestId = decodedMessage.requestId
76
112
 
77
- try {
78
- switch (decodedMessage._tag) {
79
- case 'WSMessage.PullReq': {
80
- const cursor = decodedMessage.cursor
81
- const CHUNK_SIZE = 100
113
+ return Effect.gen(this, function* () {
114
+ const { storeId } = yield* Schema.decode(WebSocketAttachmentSchema)(ws.deserializeAttachment())
115
+ const storage = makeStorage(this.ctx, this.env, storeId)
116
+
117
+ try {
118
+ switch (decodedMessage._tag) {
119
+ // TODO allow pulling concurrently to not block incoming push requests
120
+ case 'WSMessage.PullReq': {
121
+ if (options?.onPull) {
122
+ yield* Effect.tryAll(() => options.onPull!(decodedMessage))
123
+ }
82
124
 
83
- // TODO use streaming
84
- const remainingEvents = [...(yield* Effect.promise(() => this.storage.getEvents(cursor)))]
125
+ const respond = (message: WSMessage.PullRes) =>
126
+ Effect.gen(function* () {
127
+ if (options?.onPullRes) {
128
+ yield* Effect.tryAll(() => options.onPullRes!(message))
129
+ }
130
+ ws.send(encodeOutgoingMessage(message))
131
+ })
85
132
 
86
- // NOTE we want to make sure the WS server responds at least once with `InitRes` even if `events` is empty
87
- while (true) {
88
- const events = remainingEvents.splice(0, CHUNK_SIZE)
133
+ const cursor = decodedMessage.cursor
89
134
 
90
- ws.send(
91
- encodeOutgoingMessage(WSMessage.PullRes.make({ events, remaining: remainingEvents.length, requestId })),
92
- )
135
+ // TODO use streaming
136
+ const remainingEvents = yield* storage.getEvents(cursor)
137
+
138
+ // Send at least one response, even if there are no events
139
+ const batches =
140
+ remainingEvents.length === 0
141
+ ? [[]]
142
+ : Array.from({ length: Math.ceil(remainingEvents.length / PULL_CHUNK_SIZE) }, (_, i) =>
143
+ remainingEvents.slice(i * PULL_CHUNK_SIZE, (i + 1) * PULL_CHUNK_SIZE),
144
+ )
93
145
 
94
- if (remainingEvents.length === 0) {
95
- break
146
+ for (const [index, batch] of batches.entries()) {
147
+ const remaining = Math.max(0, remainingEvents.length - (index + 1) * PULL_CHUNK_SIZE)
148
+ yield* respond(WSMessage.PullRes.make({ batch, remaining, requestId: { context: 'pull', requestId } }))
96
149
  }
150
+
151
+ break
97
152
  }
153
+ case 'WSMessage.PushReq': {
154
+ const respond = (message: WSMessage.PushAck | WSMessage.Error) =>
155
+ Effect.gen(function* () {
156
+ if (options?.onPushRes) {
157
+ yield* Effect.tryAll(() => options.onPushRes!(message))
158
+ }
159
+ ws.send(encodeOutgoingMessage(message))
160
+ })
98
161
 
99
- break
100
- }
101
- case 'WSMessage.PushReq': {
102
- // TODO check whether we could use the Durable Object storage for this to speed up the lookup
103
- const latestEvent = yield* Effect.promise(() => this.storage.getLatestEvent())
104
- const expectedParentId = latestEvent?.id ?? EventId.ROOT
105
-
106
- let i = 0
107
- for (const mutationEventEncoded of decodedMessage.batch) {
108
- if (mutationEventEncoded.parentId.global !== expectedParentId.global + i) {
162
+ if (decodedMessage.batch.length === 0) {
163
+ yield* respond(WSMessage.PushAck.make({ requestId }))
164
+ return
165
+ }
166
+
167
+ yield* this.pushSemaphore.take(1)
168
+
169
+ if (options?.onPush) {
170
+ yield* Effect.tryAll(() => options.onPush!(decodedMessage))
171
+ }
172
+
173
+ // TODO check whether we could use the Durable Object storage for this to speed up the lookup
174
+ const expectedParentId = yield* storage.getHead
175
+
176
+ // TODO handle clientId unique conflict
177
+ // Validate the batch
178
+ const firstEvent = decodedMessage.batch[0]!
179
+ if (firstEvent.parentId !== expectedParentId) {
109
180
  const err = WSMessage.Error.make({
110
- message: `Invalid parent id. Received ${mutationEventEncoded.parentId.global} but expected ${expectedParentId.global}`,
181
+ message: `Invalid parent id. Received e${firstEvent.parentId} but expected e${expectedParentId}`,
111
182
  requestId,
112
183
  })
113
184
 
114
- yield* Effect.fail(err).pipe(Effect.ignoreLogged)
185
+ yield* Effect.logError(err)
115
186
 
116
- ws.send(encodeOutgoingMessage(err))
187
+ yield* respond(err)
188
+ yield* this.pushSemaphore.release(1)
117
189
  return
118
190
  }
119
191
 
120
- // TODO handle clientId unique conflict
192
+ yield* respond(WSMessage.PushAck.make({ requestId }))
121
193
 
122
- const createdAt = new Date().toISOString()
194
+ yield* this.pushSemaphore.release(1)
123
195
 
124
- // NOTE we're currently not blocking on this to allow broadcasting right away
125
- const storePromise = this.storage.appendEvent(mutationEventEncoded, createdAt)
126
-
127
- ws.send(
128
- encodeOutgoingMessage(
129
- WSMessage.PushAck.make({ mutationId: mutationEventEncoded.id.global, requestId }),
130
- ),
131
- )
196
+ const createdAt = new Date().toISOString()
132
197
 
133
- // console.debug(`Broadcasting mutation event to ${this.subscribedWebSockets.size} clients`)
198
+ // NOTE we're not waiting for this to complete yet to allow the broadcast to happen right away
199
+ // while letting the async storage write happen in the background
200
+ const storeFiber = yield* storage.appendEvents(decodedMessage.batch, createdAt).pipe(Effect.fork)
134
201
 
135
202
  const connectedClients = this.ctx.getWebSockets()
136
203
 
204
+ // console.debug(`Broadcasting push batch to ${this.subscribedWebSockets.size} clients`)
137
205
  if (connectedClients.length > 0) {
138
- const broadcastMessage = encodeOutgoingMessage(
139
- // TODO refactor to batch api
140
- WSMessage.PushBroadcast.make({
141
- mutationEventEncoded,
206
+ // TODO refactor to batch api
207
+ const pullRes = WSMessage.PullRes.make({
208
+ batch: decodedMessage.batch.map((eventEncoded) => ({
209
+ eventEncoded,
142
210
  metadata: Option.some({ createdAt }),
143
- }),
144
- )
211
+ })),
212
+ remaining: 0,
213
+ requestId: { context: 'push', requestId },
214
+ })
215
+ const pullResEnc = encodeOutgoingMessage(pullRes)
145
216
 
217
+ // Only calling once for now.
218
+ if (options?.onPullRes) {
219
+ yield* Effect.tryAll(() => options.onPullRes!(pullRes))
220
+ }
221
+
222
+ // NOTE we're also sending the pullRes to the pushing ws client as a confirmation
146
223
  for (const conn of connectedClients) {
147
- console.log('Broadcasting to client', conn === ws ? 'self' : 'other')
148
- // if (conn !== ws) {
149
- conn.send(broadcastMessage)
150
- // }
224
+ conn.send(pullResEnc)
151
225
  }
152
226
  }
153
227
 
154
- yield* Effect.promise(() => storePromise)
228
+ // Wait for the storage write to complete before finishing this request
229
+ yield* storeFiber
155
230
 
156
- i++
157
- }
158
-
159
- break
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
231
+ break
165
232
  }
233
+ case 'WSMessage.AdminResetRoomReq': {
234
+ if (decodedMessage.adminSecret !== this.env.ADMIN_SECRET) {
235
+ ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: 'Invalid admin secret', requestId })))
236
+ return
237
+ }
166
238
 
167
- yield* Effect.promise(() => this.storage.resetRoom())
168
- ws.send(encodeOutgoingMessage(WSMessage.AdminResetRoomRes.make({ requestId })))
239
+ yield* storage.resetStore
240
+ ws.send(encodeOutgoingMessage(WSMessage.AdminResetRoomRes.make({ requestId })))
169
241
 
170
- break
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
242
+ break
176
243
  }
244
+ case 'WSMessage.AdminInfoReq': {
245
+ if (decodedMessage.adminSecret !== this.env.ADMIN_SECRET) {
246
+ ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: 'Invalid admin secret', requestId })))
247
+ return
248
+ }
177
249
 
178
- ws.send(
179
- encodeOutgoingMessage(
180
- WSMessage.AdminInfoRes.make({ requestId, info: { durableObjectId: this.ctx.id.toString() } }),
181
- ),
182
- )
250
+ ws.send(
251
+ encodeOutgoingMessage(
252
+ WSMessage.AdminInfoRes.make({ requestId, info: { durableObjectId: this.ctx.id.toString() } }),
253
+ ),
254
+ )
183
255
 
184
- break
185
- }
186
- default: {
187
- console.error('unsupported message', decodedMessage)
188
- return shouldNeverHappen()
256
+ break
257
+ }
258
+ default: {
259
+ console.error('unsupported message', decodedMessage)
260
+ return shouldNeverHappen()
261
+ }
189
262
  }
263
+ } catch (error: any) {
264
+ ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: error.message, requestId })))
190
265
  }
191
- } catch (error: any) {
192
- ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: error.message, requestId })))
193
- }
194
- }).pipe(
195
- Effect.withSpan('@livestore/sync-cf:durable-object:webSocketMessage'),
196
- Effect.tapCauseLogPretty,
197
- Logger.withMinimumLogLevel(LogLevel.Debug),
198
- Effect.provide(Logger.pretty),
199
- Effect.runPromise,
200
- )
201
-
202
- webSocketClose = async (ws: WebSocketClient, code: number, _reason: string, _wasClean: boolean) => {
203
- // If the client closes the connection, the runtime will invoke the webSocketClose() handler.
204
- ws.close(code, 'Durable Object is closing WebSocket')
205
- }
206
- }
266
+ }).pipe(
267
+ Effect.withSpan(`@livestore/sync-cf:durable-object:webSocketMessage:${decodedMessage._tag}`, {
268
+ attributes: { requestId },
269
+ }),
270
+ Effect.tapCauseLogPretty,
271
+ Logger.withMinimumLogLevel(LogLevel.Debug),
272
+ Effect.provide(Logger.prettyWithThread('durable-object')),
273
+ Effect.runPromise,
274
+ )
275
+ }
207
276
 
208
- const makeStorage = (ctx: DurableObjectState, env: Env, dbName: string) => {
209
- const getLatestEvent = async (): Promise<MutationEvent.Any | undefined> => {
210
- const rawEvents = await env.DB.prepare(`SELECT * FROM ${dbName} ORDER BY idGlobal DESC LIMIT 1`).all()
211
- if (rawEvents.error) {
212
- throw new Error(rawEvents.error)
277
+ webSocketClose = async (ws: WebSocketClient, code: number, _reason: string, _wasClean: boolean) => {
278
+ // If the client closes the connection, the runtime will invoke the webSocketClose() handler.
279
+ ws.close(code, 'Durable Object is closing WebSocket')
213
280
  }
214
- const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results).map((e) => ({
215
- ...e,
216
- // TODO remove local ids
217
- id: { global: e.idGlobal, local: 0 },
218
- parentId: { global: e.parentIdGlobal, local: 0 },
219
- }))
220
- return events[0]
221
281
  }
282
+ }
222
283
 
223
- const getEvents = async (
284
+ type SyncStorage = {
285
+ dbName: string
286
+ getHead: Effect.Effect<EventId.GlobalEventId, UnexpectedError>
287
+ getEvents: (
224
288
  cursor: number | undefined,
225
- ): Promise<
226
- ReadonlyArray<{ mutationEventEncoded: MutationEvent.AnyEncoded; metadata: Option.Option<SyncMetadata> }>
227
- > => {
228
- const whereClause = cursor === undefined ? '' : `WHERE idGlobal > ${cursor}`
229
- const sql = `SELECT * FROM ${dbName} ${whereClause} ORDER BY idGlobal ASC`
230
- // TODO handle case where `cursor` was not found
231
- const rawEvents = await env.DB.prepare(sql).all()
232
- if (rawEvents.error) {
233
- throw new Error(rawEvents.error)
234
- }
235
- const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results).map(
236
- ({ createdAt, ...e }) => ({
237
- mutationEventEncoded: {
238
- ...e,
239
- // TODO remove local ids
240
- id: { global: e.idGlobal, local: 0 },
241
- parentId: { global: e.parentIdGlobal, local: 0 },
242
- },
243
- metadata: Option.some({ createdAt }),
244
- }),
289
+ ) => Effect.Effect<
290
+ ReadonlyArray<{ eventEncoded: LiveStoreEvent.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> }>,
291
+ UnexpectedError
292
+ >
293
+ appendEvents: (
294
+ batch: ReadonlyArray<LiveStoreEvent.AnyEncodedGlobal>,
295
+ createdAt: string,
296
+ ) => Effect.Effect<void, UnexpectedError>
297
+ resetStore: Effect.Effect<void, UnexpectedError>
298
+ }
299
+
300
+ const makeStorage = (ctx: DurableObjectState, env: Env, storeId: string): SyncStorage => {
301
+ const dbName = `eventlog_${PERSISTENCE_FORMAT_VERSION}_${toValidTableName(storeId)}`
302
+
303
+ const execDb = <T>(cb: (db: D1Database) => Promise<D1Result<T>>) =>
304
+ Effect.tryPromise({
305
+ try: () => cb(env.DB),
306
+ catch: (error) => new UnexpectedError({ cause: error, payload: { dbName } }),
307
+ }).pipe(
308
+ Effect.map((_) => _.results),
309
+ Effect.withSpan('@livestore/sync-cf:durable-object:execDb'),
245
310
  )
246
- return events
247
- }
248
311
 
249
- const appendEvent = async (event: MutationEvent.Any, createdAt: string) => {
250
- const sql = `INSERT INTO ${dbName} (idGlobal, parentIdGlobal, args, mutation, createdAt) VALUES (?, ?, ?, ?, ?)`
251
- await env.DB.prepare(sql)
252
- .bind(event.id.global, event.parentId.global, JSON.stringify(event.args), event.mutation, createdAt)
253
- .run()
254
- }
312
+ const getHead: Effect.Effect<EventId.GlobalEventId, UnexpectedError> = Effect.gen(function* () {
313
+ const result = yield* execDb<{ id: EventId.GlobalEventId }>((db) =>
314
+ db.prepare(`SELECT id FROM ${dbName} ORDER BY id DESC LIMIT 1`).all(),
315
+ )
255
316
 
256
- const resetRoom = async () => {
257
- await ctx.storage.deleteAll()
258
- }
317
+ return result[0]?.id ?? EventId.ROOT.global
318
+ }).pipe(UnexpectedError.mapToUnexpectedError)
259
319
 
260
- return { getLatestEvent, getEvents, appendEvent, resetRoom }
320
+ const getEvents = (
321
+ cursor: number | undefined,
322
+ ): Effect.Effect<
323
+ ReadonlyArray<{ eventEncoded: LiveStoreEvent.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> }>,
324
+ UnexpectedError
325
+ > =>
326
+ Effect.gen(function* () {
327
+ const whereClause = cursor === undefined ? '' : `WHERE id > ${cursor}`
328
+ const sql = `SELECT * FROM ${dbName} ${whereClause} ORDER BY id ASC`
329
+ // TODO handle case where `cursor` was not found
330
+ const rawEvents = yield* execDb((db) => db.prepare(sql).all())
331
+ const events = Schema.decodeUnknownSync(Schema.Array(eventlogTable.rowSchema))(rawEvents).map(
332
+ ({ createdAt, ...eventEncoded }) => ({
333
+ eventEncoded,
334
+ metadata: Option.some({ createdAt }),
335
+ }),
336
+ )
337
+ return events
338
+ }).pipe(UnexpectedError.mapToUnexpectedError)
339
+
340
+ const appendEvents: SyncStorage['appendEvents'] = (batch, createdAt) =>
341
+ Effect.gen(function* () {
342
+ // If there are no events, do nothing.
343
+ if (batch.length === 0) return
344
+
345
+ // CF D1 limits:
346
+ // Maximum bound parameters per query 100, Maximum arguments per SQL function 32
347
+ // Thus we need to split the batch into chunks of max (100/7=)14 events each.
348
+ const CHUNK_SIZE = 14
349
+
350
+ for (let i = 0; i < batch.length; i += CHUNK_SIZE) {
351
+ const chunk = batch.slice(i, i + CHUNK_SIZE)
352
+
353
+ // Create a list of placeholders ("(?, ?, ?, ?, ?, ?, ?)"), corresponding to each event.
354
+ const valuesPlaceholders = chunk.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')
355
+ const sql = `INSERT INTO ${dbName} (id, parentId, args, name, createdAt, clientId, sessionId) VALUES ${valuesPlaceholders}`
356
+ // Flatten the event properties into a parameters array.
357
+ const params = chunk.flatMap((event) => [
358
+ event.id,
359
+ event.parentId,
360
+ JSON.stringify(event.args),
361
+ event.name,
362
+ createdAt,
363
+ event.clientId,
364
+ event.sessionId,
365
+ ])
366
+
367
+ yield* execDb((db) =>
368
+ db
369
+ .prepare(sql)
370
+ .bind(...params)
371
+ .run(),
372
+ )
373
+ }
374
+ }).pipe(UnexpectedError.mapToUnexpectedError)
375
+
376
+ const resetStore = Effect.gen(function* () {
377
+ yield* Effect.promise(() => ctx.storage.deleteAll())
378
+ }).pipe(UnexpectedError.mapToUnexpectedError)
379
+
380
+ return { dbName, getHead, getEvents, appendEvents, resetStore }
261
381
  }
382
+
383
+ const getStoreId = (request: Request) => {
384
+ const url = new URL(request.url)
385
+ const searchParams = url.searchParams
386
+ const storeId = searchParams.get('storeId')
387
+ if (storeId === null) {
388
+ throw new Error('storeId search param is required')
389
+ }
390
+ return storeId
391
+ }
392
+
393
+ 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,85 @@
1
+ import { UnexpectedError } from '@livestore/common'
2
+ import type { Schema } from '@livestore/utils/effect'
3
+ import { Effect, UrlParams } from '@livestore/utils/effect'
4
+
5
+ import { SearchParamsSchema } from '../common/mod.js'
6
+ import type { Env } from './durable-object.js'
7
+
8
+ export type CFWorker = {
9
+ fetch: (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response>
10
+ }
11
+
12
+ export type MakeWorkerOptions = {
13
+ validatePayload?: (payload: Schema.JsonValue | undefined) => void | Promise<void>
14
+ /** @default false */
15
+ enableCORS?: boolean
16
+ }
17
+
18
+ export const makeWorker = (options: MakeWorkerOptions = {}): CFWorker => {
19
+ return {
20
+ fetch: async (request, env, _ctx) =>
21
+ Effect.gen(function* () {
22
+ const url = new URL(request.url)
23
+ const urlParams = UrlParams.fromInput(url.searchParams)
24
+ const paramsResult = yield* UrlParams.schemaStruct(SearchParamsSchema)(urlParams).pipe(Effect.either)
25
+
26
+ const corsHeaders: HeadersInit = options.enableCORS
27
+ ? {
28
+ 'Access-Control-Allow-Origin': '*',
29
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
30
+ 'Access-Control-Allow-Headers': request.headers.get('Access-Control-Request-Headers') ?? '*',
31
+ }
32
+ : {}
33
+
34
+ if (request.method === 'OPTIONS' && options.enableCORS) {
35
+ return new Response(null, {
36
+ status: 204,
37
+ headers: corsHeaders,
38
+ })
39
+ }
40
+
41
+ if (paramsResult._tag === 'Left') {
42
+ return new Response(`Invalid search params: ${paramsResult.left.toString()}`, {
43
+ status: 500,
44
+ headers: corsHeaders,
45
+ })
46
+ }
47
+
48
+ const { storeId, payload } = paramsResult.right
49
+
50
+ if (options.validatePayload !== undefined) {
51
+ const result = yield* Effect.promise(async () => options.validatePayload!(payload)).pipe(
52
+ UnexpectedError.mapToUnexpectedError,
53
+ Effect.either,
54
+ )
55
+
56
+ if (result._tag === 'Left') {
57
+ console.error('Invalid payload', result.left)
58
+ return new Response(result.left.toString(), { status: 400, headers: corsHeaders })
59
+ }
60
+ }
61
+
62
+ const id = env.WEBSOCKET_SERVER.idFromName(storeId)
63
+ const durableObject = env.WEBSOCKET_SERVER.get(id)
64
+
65
+ if (url.pathname.endsWith('/websocket')) {
66
+ const upgradeHeader = request.headers.get('Upgrade')
67
+ if (!upgradeHeader || upgradeHeader !== 'websocket') {
68
+ return new Response('Durable Object expected Upgrade: websocket', { status: 426, headers: corsHeaders })
69
+ }
70
+
71
+ return durableObject.fetch(request)
72
+ }
73
+
74
+ console.error('Invalid path', url.pathname)
75
+ return new Response(null, {
76
+ status: 400,
77
+ statusText: 'Bad Request',
78
+ headers: {
79
+ ...corsHeaders,
80
+ 'Content-Type': 'text/plain',
81
+ },
82
+ })
83
+ }).pipe(Effect.tapCauseLogPretty, Effect.runPromise),
84
+ }
85
+ }
@@ -0,0 +1,8 @@
1
+ import { Schema } from '@livestore/utils/effect'
2
+
3
+ export * as WSMessage from './ws-message-types.js'
4
+
5
+ export const SearchParamsSchema = Schema.Struct({
6
+ storeId: Schema.String,
7
+ payload: Schema.compose(Schema.StringFromUriComponent, Schema.parseJson(Schema.JsonValue)).pipe(Schema.UndefinedOr),
8
+ })