@livestore/sync-cf 0.3.0-dev.9 → 0.3.0

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