@livestore/sync-cf 0.3.0-dev.11 → 0.3.0-dev.12

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.
@@ -1,4 +1,4 @@
1
- import { makeColumnSpec } from '@livestore/common'
1
+ import { makeColumnSpec, UnexpectedError } from '@livestore/common'
2
2
  import { DbSchema, EventId, type MutationEvent } from '@livestore/common/schema'
3
3
  import { shouldNeverHappen } from '@livestore/utils'
4
4
  import { Effect, Logger, LogLevel, Option, Schema } from '@livestore/utils/effect'
@@ -19,8 +19,8 @@ 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', {
22
+ // NOTE actual table name is determined at runtime
23
+ export const mutationLogTable = DbSchema.table('mutation_log_${PERSISTENCE_FORMAT_VERSION}_${storeId}', {
24
24
  id: DbSchema.integer({ primaryKey: true, schema: EventId.GlobalEventId }),
25
25
  parentId: DbSchema.integer({ schema: EventId.GlobalEventId }),
26
26
  mutation: DbSchema.text({}),
@@ -104,6 +104,7 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
104
104
 
105
105
  try {
106
106
  switch (decodedMessage._tag) {
107
+ // TODO allow pulling concurrently to not block incoming push requests
107
108
  case 'WSMessage.PullReq': {
108
109
  if (options?.onPull) {
109
110
  yield* Effect.tryAll(() => options.onPull!(decodedMessage))
@@ -113,21 +114,14 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
113
114
  const CHUNK_SIZE = 100
114
115
 
115
116
  // TODO use streaming
116
- const remainingEvents = [...(yield* Effect.promise(() => storage.getEvents(cursor)))]
117
+ const remainingEvents = yield* storage.getEvents(cursor)
117
118
 
118
119
  // 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)
120
+ for (let i = 0; i < remainingEvents.length; i += CHUNK_SIZE) {
121
+ const batch = remainingEvents.slice(i, i + CHUNK_SIZE)
122
+ const remaining = remainingEvents.length - i - 1
121
123
 
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
- }
124
+ ws.send(encodeOutgoingMessage(WSMessage.PullRes.make({ batch, remaining })))
131
125
  }
132
126
 
133
127
  break
@@ -137,61 +131,63 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
137
131
  yield* Effect.tryAll(() => options.onPush!(decodedMessage))
138
132
  }
139
133
 
134
+ if (decodedMessage.batch.length === 0) {
135
+ ws.send(encodeOutgoingMessage(WSMessage.PushAck.make({ requestId })))
136
+ return
137
+ }
138
+
140
139
  // 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
140
+ const expectedParentId = yield* storage.getHead
143
141
 
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
- })
142
+ // TODO handle clientId unique conflict
151
143
 
152
- yield* Effect.fail(err).pipe(Effect.ignoreLogged)
144
+ // Validate the batch
145
+ const firstEvent = decodedMessage.batch[0]!
146
+ if (firstEvent.parentId !== expectedParentId) {
147
+ const err = WSMessage.Error.make({
148
+ message: `Invalid parent id. Received ${firstEvent.parentId} but expected ${expectedParentId}`,
149
+ requestId,
150
+ })
153
151
 
154
- ws.send(encodeOutgoingMessage(err))
155
- return
156
- }
152
+ yield* Effect.logError(err)
157
153
 
158
- // TODO handle clientId unique conflict
154
+ ws.send(encodeOutgoingMessage(err))
155
+ return
156
+ }
159
157
 
160
- const createdAt = new Date().toISOString()
158
+ ws.send(encodeOutgoingMessage(WSMessage.PushAck.make({ requestId })))
161
159
 
162
- // NOTE we're currently not blocking on this to allow broadcasting right away
163
- const storePromise = storage.appendEvent(mutationEventEncoded, createdAt)
160
+ const createdAt = new Date().toISOString()
164
161
 
165
- ws.send(
166
- encodeOutgoingMessage(WSMessage.PushAck.make({ mutationId: mutationEventEncoded.id, requestId })),
167
- )
162
+ // NOTE we're not waiting for this to complete yet to allow the broadcast to happen right away
163
+ // while letting the async storage write happen in the background
164
+ const storeFiber = yield* storage.appendEvents(decodedMessage.batch, createdAt).pipe(Effect.fork)
168
165
 
169
- // console.debug(`Broadcasting mutation event to ${this.subscribedWebSockets.size} clients`)
166
+ const connectedClients = this.ctx.getWebSockets()
170
167
 
171
- const connectedClients = this.ctx.getWebSockets()
168
+ // console.debug(`Broadcasting push batch to ${this.subscribedWebSockets.size} clients`)
172
169
 
173
- if (connectedClients.length > 0) {
174
- const broadcastMessage = encodeOutgoingMessage(
175
- // TODO refactor to batch api
176
- WSMessage.PushBroadcast.make({
170
+ if (connectedClients.length > 0) {
171
+ const pullRes = encodeOutgoingMessage(
172
+ // TODO refactor to batch api
173
+ WSMessage.PullRes.make({
174
+ batch: decodedMessage.batch.map((mutationEventEncoded) => ({
177
175
  mutationEventEncoded,
178
176
  metadata: Option.some({ createdAt }),
179
- }),
180
- )
181
-
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
- }
189
-
190
- yield* Effect.promise(() => storePromise)
177
+ })),
178
+ remaining: 0,
179
+ }),
180
+ )
191
181
 
192
- i++
182
+ // NOTE we're also sending the pullRes to the pushing ws client as a confirmation
183
+ for (const conn of connectedClients) {
184
+ conn.send(pullRes)
185
+ }
193
186
  }
194
187
 
188
+ // Wait for the storage write to complete before finishing this request
189
+ yield* storeFiber
190
+
195
191
  break
196
192
  }
197
193
  case 'WSMessage.AdminResetRoomReq': {
@@ -200,7 +196,7 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
200
196
  return
201
197
  }
202
198
 
203
- yield* Effect.promise(() => storage.resetRoom())
199
+ yield* storage.resetStore
204
200
  ws.send(encodeOutgoingMessage(WSMessage.AdminResetRoomRes.make({ requestId })))
205
201
 
206
202
  break
@@ -244,62 +240,96 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
244
240
 
245
241
  type SyncStorage = {
246
242
  dbName: string
247
- getLatestEvent: () => Promise<MutationEvent.AnyEncodedGlobal | undefined>
243
+ getHead: Effect.Effect<EventId.GlobalEventId, UnexpectedError>
248
244
  getEvents: (
249
245
  cursor: number | undefined,
250
- ) => Promise<
251
- ReadonlyArray<{ mutationEventEncoded: MutationEvent.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> }>
246
+ ) => Effect.Effect<
247
+ ReadonlyArray<{ mutationEventEncoded: MutationEvent.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> }>,
248
+ UnexpectedError
252
249
  >
253
- appendEvent: (event: MutationEvent.AnyEncodedGlobal, createdAt: string) => Promise<void>
254
- resetRoom: () => Promise<void>
250
+ appendEvents: (
251
+ batch: ReadonlyArray<MutationEvent.AnyEncodedGlobal>,
252
+ createdAt: string,
253
+ ) => Effect.Effect<void, UnexpectedError>
254
+ resetStore: Effect.Effect<void, UnexpectedError>
255
255
  }
256
256
 
257
257
  const makeStorage = (ctx: DurableObjectState, env: Env, storeId: string): SyncStorage => {
258
258
  const dbName = `mutation_log_${PERSISTENCE_FORMAT_VERSION}_${toValidTableName(storeId)}`
259
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()
262
- if (rawEvents.error) {
263
- throw new Error(rawEvents.error)
264
- }
265
- const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results)
266
-
267
- return events[0]
268
- }
260
+ const execDb = <T>(cb: (db: D1Database) => Promise<D1Result<T>>) =>
261
+ Effect.tryPromise({
262
+ try: () => cb(env.DB),
263
+ catch: (error) => new UnexpectedError({ cause: error, payload: { dbName } }),
264
+ }).pipe(Effect.map((_) => _.results))
269
265
 
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`
277
- // TODO handle case where `cursor` was not found
278
- const rawEvents = await env.DB.prepare(sql).all()
279
- if (rawEvents.error) {
280
- throw new Error(rawEvents.error)
281
- }
282
- const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results).map(
283
- ({ createdAt, ...mutationEventEncoded }) => ({
284
- mutationEventEncoded,
285
- metadata: Option.some({ createdAt }),
286
- }),
266
+ const getHead: Effect.Effect<EventId.GlobalEventId, UnexpectedError> = Effect.gen(function* () {
267
+ const result = yield* execDb<{ id: EventId.GlobalEventId }>((db) =>
268
+ db.prepare(`SELECT id FROM ${dbName} ORDER BY id DESC LIMIT 1`).all(),
287
269
  )
288
- return events
289
- }
290
270
 
291
- const appendEvent = async (event: MutationEvent.AnyEncodedGlobal, createdAt: string) => {
292
- const sql = `INSERT INTO ${dbName} (id, parentId, args, mutation, createdAt) VALUES (?, ?, ?, ?, ?)`
293
- await env.DB.prepare(sql)
294
- .bind(event.id, event.parentId, JSON.stringify(event.args), event.mutation, createdAt)
295
- .run()
296
- }
271
+ return result[0]?.id ?? EventId.ROOT.global
272
+ }).pipe(UnexpectedError.mapToUnexpectedError)
297
273
 
298
- const resetRoom = async () => {
299
- await ctx.storage.deleteAll()
300
- }
274
+ const getEvents = (
275
+ cursor: number | undefined,
276
+ ): Effect.Effect<
277
+ ReadonlyArray<{ mutationEventEncoded: MutationEvent.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> }>,
278
+ UnexpectedError
279
+ > =>
280
+ Effect.gen(function* () {
281
+ const whereClause = cursor === undefined ? '' : `WHERE id > ${cursor}`
282
+ const sql = `SELECT * FROM ${dbName} ${whereClause} ORDER BY id ASC`
283
+ // TODO handle case where `cursor` was not found
284
+ const rawEvents = yield* execDb((db) => db.prepare(sql).all())
285
+ const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents).map(
286
+ ({ createdAt, ...mutationEventEncoded }) => ({
287
+ mutationEventEncoded,
288
+ metadata: Option.some({ createdAt }),
289
+ }),
290
+ )
291
+ return events
292
+ })
293
+
294
+ const appendEvents: SyncStorage['appendEvents'] = (batch, createdAt) =>
295
+ Effect.gen(function* () {
296
+ // If there are no events, do nothing.
297
+ if (batch.length === 0) return
298
+
299
+ // CF D1 limits:
300
+ // Maximum bound parameters per query 100, Maximum arguments per SQL function 32
301
+ // Thus we need to split the batch into chunks of max (100/5=)20 events each.
302
+ const CHUNK_SIZE = 20
303
+
304
+ for (let i = 0; i < batch.length; i += CHUNK_SIZE) {
305
+ const chunk = batch.slice(i, i + CHUNK_SIZE)
306
+
307
+ // Create a list of placeholders ("(?, ?, ?, ?, ?), …") corresponding to each event.
308
+ const valuesPlaceholders = chunk.map(() => '(?, ?, ?, ?, ?)').join(', ')
309
+ const sql = `INSERT INTO ${dbName} (id, parentId, args, mutation, createdAt) VALUES ${valuesPlaceholders}`
310
+ // Flatten the event properties into a parameters array.
311
+ const params = chunk.flatMap((event) => [
312
+ event.id,
313
+ event.parentId,
314
+ JSON.stringify(event.args),
315
+ event.mutation,
316
+ createdAt,
317
+ ])
318
+
319
+ yield* execDb((db) =>
320
+ db
321
+ .prepare(sql)
322
+ .bind(...params)
323
+ .run(),
324
+ )
325
+ }
326
+ })
327
+
328
+ const resetStore = Effect.gen(function* () {
329
+ yield* Effect.promise(() => ctx.storage.deleteAll())
330
+ }).pipe(UnexpectedError.mapToUnexpectedError)
301
331
 
302
- return { dbName, getLatestEvent, getEvents, appendEvent, resetRoom }
332
+ return { dbName, getHead, getEvents, appendEvents, resetStore }
303
333
  }
304
334
 
305
335
  const getStoreId = (request: Request) => {
@@ -17,8 +17,7 @@ export const SyncMetadata = Schema.Struct({
17
17
  export type SyncMetadata = typeof SyncMetadata.Type
18
18
 
19
19
  export const PullRes = Schema.TaggedStruct('WSMessage.PullRes', {
20
- requestId: Schema.String,
21
- events: Schema.Array(
20
+ batch: Schema.Array(
22
21
  Schema.Struct({
23
22
  mutationEventEncoded: MutationEvent.AnyEncodedGlobal,
24
23
  metadata: Schema.Option(SyncMetadata),
@@ -29,13 +28,6 @@ export const PullRes = Schema.TaggedStruct('WSMessage.PullRes', {
29
28
 
30
29
  export type PullRes = typeof PullRes.Type
31
30
 
32
- export const PushBroadcast = Schema.TaggedStruct('WSMessage.PushBroadcast', {
33
- mutationEventEncoded: MutationEvent.AnyEncodedGlobal,
34
- metadata: Schema.Option(SyncMetadata),
35
- })
36
-
37
- export type PushBroadcast = typeof PushBroadcast.Type
38
-
39
31
  export const PushReq = Schema.TaggedStruct('WSMessage.PushReq', {
40
32
  requestId: Schema.String,
41
33
  batch: Schema.Array(MutationEvent.AnyEncodedGlobal),
@@ -45,7 +37,6 @@ export type PushReq = typeof PushReq.Type
45
37
 
46
38
  export const PushAck = Schema.TaggedStruct('WSMessage.PushAck', {
47
39
  requestId: Schema.String,
48
- mutationId: Schema.Number,
49
40
  })
50
41
 
51
42
  export type PushAck = typeof PushAck.Type
@@ -99,7 +90,6 @@ export type AdminInfoRes = typeof AdminInfoRes.Type
99
90
  export const Message = Schema.Union(
100
91
  PullReq,
101
92
  PullRes,
102
- PushBroadcast,
103
93
  PushReq,
104
94
  PushAck,
105
95
  Error,
@@ -113,15 +103,7 @@ export const Message = Schema.Union(
113
103
  export type Message = typeof Message.Type
114
104
  export type MessageEncoded = typeof Message.Encoded
115
105
 
116
- export const BackendToClientMessage = Schema.Union(
117
- PullRes,
118
- PushBroadcast,
119
- PushAck,
120
- AdminResetRoomRes,
121
- AdminInfoRes,
122
- Error,
123
- Pong,
124
- )
106
+ export const BackendToClientMessage = Schema.Union(PullRes, PushAck, AdminResetRoomRes, AdminInfoRes, Error, Pong)
125
107
  export type BackendToClientMessage = typeof BackendToClientMessage.Type
126
108
 
127
109
  export const ClientToBackendMessage = Schema.Union(PullReq, PushReq, AdminResetRoomReq, AdminInfoReq, Ping)
@@ -2,7 +2,7 @@
2
2
 
3
3
  import type { SyncBackend } from '@livestore/common'
4
4
  import { InvalidPullError, InvalidPushError } from '@livestore/common'
5
- import { pick } from '@livestore/utils'
5
+ import { LS_DEV } from '@livestore/utils'
6
6
  import type { Scope } from '@livestore/utils/effect'
7
7
  import {
8
8
  Deferred,
@@ -43,24 +43,13 @@ export const makeWsSync = (options: WsSyncOptions): Effect.Effect<SyncBackend<Sy
43
43
  yield* send(WSMessage.PullReq.make({ cursor, requestId }))
44
44
 
45
45
  return Stream.fromPubSub(incomingMessages).pipe(
46
- Stream.filter((_) => (_._tag === 'WSMessage.PullRes' ? _.requestId === requestId : true)),
47
46
  Stream.tap((_) =>
48
47
  _._tag === 'WSMessage.Error' && _.requestId === requestId
49
48
  ? new InvalidPullError({ message: _.message })
50
49
  : Effect.void,
51
50
  ),
52
- Stream.filter(Schema.is(Schema.Union(WSMessage.PushBroadcast, WSMessage.PullRes))),
53
- Stream.map((msg) =>
54
- msg._tag === 'WSMessage.PushBroadcast'
55
- ? { batch: [pick(msg, ['mutationEventEncoded', 'metadata'])], remaining: 0 }
56
- : {
57
- batch: msg.events.map(({ mutationEventEncoded, metadata }) => ({
58
- mutationEventEncoded,
59
- metadata,
60
- })),
61
- remaining: msg.remaining,
62
- },
63
- ),
51
+ // This call is mostly here to for type narrowing
52
+ Stream.filter(Schema.is(WSMessage.PullRes)),
64
53
  )
65
54
  }).pipe(Stream.unwrap),
66
55
 
@@ -70,15 +59,12 @@ export const makeWsSync = (options: WsSyncOptions): Effect.Effect<SyncBackend<Sy
70
59
  const requestId = nanoid()
71
60
 
72
61
  yield* Stream.fromPubSub(incomingMessages).pipe(
73
- Stream.filter((_) => _._tag !== 'WSMessage.PushBroadcast' && _.requestId === requestId),
74
62
  Stream.tap((_) =>
75
- _._tag === 'WSMessage.Error'
63
+ _._tag === 'WSMessage.Error' && _.requestId === requestId
76
64
  ? Deferred.fail(ready, new InvalidPushError({ reason: { _tag: 'Unexpected', message: _.message } }))
77
65
  : Effect.void,
78
66
  ),
79
- Stream.filter(Schema.is(WSMessage.PushAck)),
80
- // TODO bring back filterting of "own events"
81
- // Stream.filter((_) => _.mutationId === mutationEventEncoded.id.global),
67
+ Stream.filter((_) => _._tag === 'WSMessage.PushAck' && _.requestId === requestId),
82
68
  Stream.take(1),
83
69
  Stream.tap(() => Deferred.succeed(ready, void 0)),
84
70
  Stream.runDrain,
@@ -115,21 +101,23 @@ const connect = (wsUrl: string) =>
115
101
  // Wait first until we're online
116
102
  yield* waitUntilOnline
117
103
 
118
- yield* Effect.spanEvent(
119
- `Sending message: ${message._tag}`,
120
- message._tag === 'WSMessage.PushReq'
121
- ? {
122
- id: message.batch[0]!.id,
123
- parentId: message.batch[0]!.parentId,
124
- batchLength: message.batch.length,
125
- }
126
- : message._tag === 'WSMessage.PullReq'
127
- ? { cursor: message.cursor ?? '-' }
128
- : {},
129
- )
130
-
131
104
  // TODO use MsgPack instead of JSON to speed up the serialization / reduce the size of the messages
132
105
  socketRef.current!.send(Schema.encodeSync(Schema.parseJson(WSMessage.Message))(message))
106
+
107
+ if (LS_DEV) {
108
+ yield* Effect.spanEvent(
109
+ `Sent message: ${message._tag}`,
110
+ message._tag === 'WSMessage.PushReq'
111
+ ? {
112
+ id: message.batch[0]!.id,
113
+ parentId: message.batch[0]!.parentId,
114
+ batchLength: message.batch.length,
115
+ }
116
+ : message._tag === 'WSMessage.PullReq'
117
+ ? { cursor: message.cursor ?? '-' }
118
+ : {},
119
+ )
120
+ }
133
121
  })
134
122
 
135
123
  const innerConnect = Effect.gen(function* () {
@@ -138,6 +126,7 @@ const connect = (wsUrl: string) =>
138
126
  while (typeof navigator !== 'undefined' && navigator.onLine === false) {
139
127
  yield* Effect.sleep(1000)
140
128
  }
129
+ // TODO bring this back in a cross-platform way
141
130
  // if (navigator.onLine === false) {
142
131
  // yield* Effect.async((cb) => self.addEventListener('online', () => cb(Effect.void)))
143
132
  // }