@livestore/sync-cf 0.2.0 → 0.3.0-dev.1

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 (34) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/cf-worker/durable-object.d.ts +21 -8
  3. package/dist/cf-worker/durable-object.d.ts.map +1 -1
  4. package/dist/cf-worker/durable-object.js +62 -48
  5. package/dist/cf-worker/durable-object.js.map +1 -1
  6. package/dist/common/ws-message-types.d.ts +113 -84
  7. package/dist/common/ws-message-types.d.ts.map +1 -1
  8. package/dist/common/ws-message-types.js +13 -9
  9. package/dist/common/ws-message-types.js.map +1 -1
  10. package/dist/sync-impl/ws-impl.d.ts +2 -1
  11. package/dist/sync-impl/ws-impl.d.ts.map +1 -1
  12. package/dist/sync-impl/ws-impl.js +66 -67
  13. package/dist/sync-impl/ws-impl.js.map +1 -1
  14. package/package.json +10 -3
  15. package/src/cf-worker/durable-object.ts +138 -109
  16. package/src/cf-worker/index.ts +1 -1
  17. package/src/common/ws-message-types.ts +18 -9
  18. package/src/sync-impl/ws-impl.ts +114 -107
  19. package/.netlify/state.json +0 -3
  20. package/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/8ee300993f9a2c909aede59646369fa0cc28d25304b9b9af94f54d4543ad60f4.sqlite +0 -0
  21. package/.wrangler/state/v3/do/websocket-server-WebSocketServer/2c3676d859102e448b271e1b4336b1e40778992b14728e2a2bd642c0f225e9c5.sqlite +0 -0
  22. package/.wrangler/state/v3/do/websocket-server-WebSocketServer/6eb1111cecdc6ae7e3a38c3d7db0f3ec7de5b318e682cdcf0ff0dc881cbbcee7.sqlite +0 -0
  23. package/.wrangler/state/v3/do/websocket-server-WebSocketServer/c4aa0e61dcd784604bee52282348e0538ab69ec76e1652322bbccd539d9ff3ee.sqlite +0 -0
  24. package/.wrangler/state/v3/do/websocket-server-WebSocketServer/d87433156774c933da83e962eba16107cf02ade2f133d7205963558fe47db3ff.sqlite +0 -0
  25. package/.wrangler/tmp/dev-33iU4b/index.js +0 -42167
  26. package/.wrangler/tmp/dev-33iU4b/index.js.map +0 -8
  27. package/.wrangler/tmp/dev-9rcIR8/index.js +0 -18887
  28. package/.wrangler/tmp/dev-9rcIR8/index.js.map +0 -8
  29. package/.wrangler/tmp/dev-rI63Kk/index.js +0 -42165
  30. package/.wrangler/tmp/dev-rI63Kk/index.js.map +0 -8
  31. package/.wrangler/tmp/dev-txPodK/index.js +0 -42118
  32. package/.wrangler/tmp/dev-txPodK/index.js.map +0 -8
  33. package/tsconfig.json +0 -12
  34. package/wrangler.toml +0 -21
@@ -1,10 +1,11 @@
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
7
  import { WSMessage } from '../common/index.js'
8
+ import type { SyncMetadata } from '../common/ws-message-types.js'
8
9
 
9
10
  export interface Env {
10
11
  WEBSOCKET_SERVER: DurableObjectNamespace<WebSocketServer>
@@ -18,11 +19,14 @@ 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
24
  idGlobal: DbSchema.integer({ primaryKey: true }),
23
25
  parentIdGlobal: DbSchema.integer({}),
24
26
  mutation: DbSchema.text({}),
25
27
  args: DbSchema.text({ schema: Schema.parseJson(Schema.Any) }),
28
+ /** ISO date format */
29
+ createdAt: DbSchema.text({}),
26
30
  })
27
31
 
28
32
  // Durable Object
@@ -58,129 +62,142 @@ export class WebSocketServer extends DurableObject<Env> {
58
62
  })
59
63
  }).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
60
64
 
61
- webSocketMessage = async (ws: WebSocketClient, message: ArrayBuffer | string) => {
62
- const decodedMessageRes = decodeIncomingMessage(message)
65
+ webSocketMessage = (ws: WebSocketClient, message: ArrayBuffer | string) =>
66
+ Effect.gen(this, function* () {
67
+ const decodedMessageRes = decodeIncomingMessage(message)
63
68
 
64
- if (decodedMessageRes._tag === 'Left') {
65
- console.error('Invalid message received', decodedMessageRes.left)
66
- return
67
- }
69
+ if (decodedMessageRes._tag === 'Left') {
70
+ console.error('Invalid message received', decodedMessageRes.left)
71
+ return
72
+ }
68
73
 
69
- const decodedMessage = decodedMessageRes.right
70
- const requestId = decodedMessage.requestId
74
+ const decodedMessage = decodedMessageRes.right
75
+ const requestId = decodedMessage.requestId
71
76
 
72
- try {
73
- switch (decodedMessage._tag) {
74
- case 'WSMessage.PullReq': {
75
- const cursor = decodedMessage.cursor
76
- const CHUNK_SIZE = 100
77
+ try {
78
+ switch (decodedMessage._tag) {
79
+ case 'WSMessage.PullReq': {
80
+ const cursor = decodedMessage.cursor
81
+ const CHUNK_SIZE = 100
77
82
 
78
- // TODO use streaming
79
- const remainingEvents = [...(await this.storage.getEvents(cursor))]
83
+ // TODO use streaming
84
+ const remainingEvents = [...(yield* Effect.promise(() => this.storage.getEvents(cursor)))]
80
85
 
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
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)
86
89
 
87
- ws.send(encodeOutgoingMessage(WSMessage.PullRes.make({ events: encodedEvents, hasMore, requestId })))
90
+ ws.send(
91
+ encodeOutgoingMessage(WSMessage.PullRes.make({ events, remaining: remainingEvents.length, requestId })),
92
+ )
88
93
 
89
- if (hasMore === false) {
90
- break
94
+ if (remainingEvents.length === 0) {
95
+ break
96
+ }
91
97
  }
98
+
99
+ break
92
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) {
109
+ const err = WSMessage.Error.make({
110
+ message: `Invalid parent id. Received ${mutationEventEncoded.parentId.global} but expected ${expectedParentId.global}`,
111
+ requestId,
112
+ })
93
113
 
94
- break
95
- }
96
- case 'WSMessage.PushReq': {
97
- // TODO check whether we could use the Durable Object storage for this to speed up the lookup
98
- const latestEvent = await this.storage.getLatestEvent()
99
- const expectedParentId = latestEvent?.id ?? ROOT_ID
114
+ yield* Effect.fail(err).pipe(Effect.ignoreLogged)
100
115
 
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
- }
116
+ ws.send(encodeOutgoingMessage(err))
117
+ return
118
+ }
112
119
 
113
- // TODO handle clientId unique conflict
120
+ // TODO handle clientId unique conflict
114
121
 
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()
122
+ const createdAt = new Date().toISOString()
119
123
 
120
- ws.send(
121
- encodeOutgoingMessage(
122
- WSMessage.PushAck.make({ mutationId: decodedMessage.mutationEventEncoded.id.global, requestId }),
123
- ),
124
- )
124
+ // NOTE we're currently not blocking on this to allow broadcasting right away
125
+ const storePromise = this.storage.appendEvent(mutationEventEncoded, createdAt)
125
126
 
126
- // console.debug(`Broadcasting mutation event to ${this.subscribedWebSockets.size} clients`)
127
+ ws.send(
128
+ encodeOutgoingMessage(
129
+ WSMessage.PushAck.make({ mutationId: mutationEventEncoded.id.global, requestId }),
130
+ ),
131
+ )
127
132
 
128
- const connectedClients = this.ctx.getWebSockets()
133
+ // console.debug(`Broadcasting mutation event to ${this.subscribedWebSockets.size} clients`)
129
134
 
130
- if (connectedClients.length > 0) {
131
- const broadcastMessage = encodeOutgoingMessage(
132
- WSMessage.PushBroadcast.make({
133
- mutationEventEncoded: decodedMessage.mutationEventEncoded,
134
- persisted: decodedMessage.persisted,
135
- }),
136
- )
135
+ const connectedClients = this.ctx.getWebSockets()
137
136
 
138
- for (const conn of connectedClients) {
139
- console.log('Broadcasting to client', conn === ws ? 'self' : 'other')
140
- // if (conn !== ws) {
141
- conn.send(broadcastMessage)
142
- // }
143
- }
144
- }
137
+ if (connectedClients.length > 0) {
138
+ const broadcastMessage = encodeOutgoingMessage(
139
+ // TODO refactor to batch api
140
+ WSMessage.PushBroadcast.make({
141
+ mutationEventEncoded,
142
+ metadata: Option.some({ createdAt }),
143
+ }),
144
+ )
145
145
 
146
- await storePromise
146
+ for (const conn of connectedClients) {
147
+ console.log('Broadcasting to client', conn === ws ? 'self' : 'other')
148
+ // if (conn !== ws) {
149
+ conn.send(broadcastMessage)
150
+ // }
151
+ }
152
+ }
147
153
 
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
+ yield* Effect.promise(() => storePromise)
155
+
156
+ i++
157
+ }
158
+
159
+ break
154
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
165
+ }
155
166
 
156
- await this.storage.resetRoom()
157
- ws.send(encodeOutgoingMessage(WSMessage.AdminResetRoomRes.make({ requestId })))
167
+ yield* Effect.promise(() => this.storage.resetRoom())
168
+ ws.send(encodeOutgoingMessage(WSMessage.AdminResetRoomRes.make({ requestId })))
158
169
 
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
170
+ break
165
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
176
+ }
166
177
 
167
- ws.send(
168
- encodeOutgoingMessage(
169
- WSMessage.AdminInfoRes.make({ requestId, info: { durableObjectId: this.ctx.id.toString() } }),
170
- ),
171
- )
178
+ ws.send(
179
+ encodeOutgoingMessage(
180
+ WSMessage.AdminInfoRes.make({ requestId, info: { durableObjectId: this.ctx.id.toString() } }),
181
+ ),
182
+ )
172
183
 
173
- break
174
- }
175
- default: {
176
- console.error('unsupported message', decodedMessage)
177
- return shouldNeverHappen()
184
+ break
185
+ }
186
+ default: {
187
+ console.error('unsupported message', decodedMessage)
188
+ return shouldNeverHappen()
189
+ }
178
190
  }
191
+ } catch (error: any) {
192
+ ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: error.message, requestId })))
179
193
  }
180
- } catch (error: any) {
181
- ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: error.message, requestId })))
182
- }
183
- }
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
+ )
184
201
 
185
202
  webSocketClose = async (ws: WebSocketClient, code: number, _reason: string, _wasClean: boolean) => {
186
203
  // If the client closes the connection, the runtime will invoke the webSocketClose() handler.
@@ -196,31 +213,43 @@ const makeStorage = (ctx: DurableObjectState, env: Env, dbName: string) => {
196
213
  }
197
214
  const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results).map((e) => ({
198
215
  ...e,
216
+ // TODO remove local ids
199
217
  id: { global: e.idGlobal, local: 0 },
200
218
  parentId: { global: e.parentIdGlobal, local: 0 },
201
219
  }))
202
220
  return events[0]
203
221
  }
204
222
 
205
- const getEvents = async (cursor: number | undefined): Promise<ReadonlyArray<MutationEvent.Any>> => {
206
- const whereClause = cursor ? `WHERE idGlobal > ${cursor}` : ''
223
+ const getEvents = async (
224
+ 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`
207
230
  // TODO handle case where `cursor` was not found
208
- const rawEvents = await env.DB.prepare(`SELECT * FROM ${dbName} ${whereClause} ORDER BY idGlobal ASC`).all()
231
+ const rawEvents = await env.DB.prepare(sql).all()
209
232
  if (rawEvents.error) {
210
233
  throw new Error(rawEvents.error)
211
234
  }
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
- }))
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
+ }),
245
+ )
217
246
  return events
218
247
  }
219
248
 
220
- const appendEvent = async (event: MutationEvent.Any) => {
221
- const sql = `INSERT INTO ${dbName} (idGlobal, parentIdGlobal, args, mutation) VALUES (?, ?, ?, ?)`
249
+ const appendEvent = async (event: MutationEvent.Any, createdAt: string) => {
250
+ const sql = `INSERT INTO ${dbName} (idGlobal, parentIdGlobal, args, mutation, createdAt) VALUES (?, ?, ?, ?, ?)`
222
251
  await env.DB.prepare(sql)
223
- .bind(event.id.global, event.parentId.global, JSON.stringify(event.args), event.mutation)
252
+ .bind(event.id.global, event.parentId.global, JSON.stringify(event.args), event.mutation, createdAt)
224
253
  .run()
225
254
  }
226
255
 
@@ -1,7 +1,7 @@
1
1
  /// <reference no-default-lib="true"/>
2
2
  /// <reference lib="esnext" />
3
3
 
4
- // import { mutationEventSchemaEncodedAny } from '@livestore/common/schema'
4
+ // import { EncodedAny } from '@livestore/common/schema'
5
5
  // import { Effect, HttpServer, Schema } from '@livestore/utils/effect'
6
6
 
7
7
  import type { Env } from './durable-object.js'
@@ -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.EncodedAny,
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.EncodedAny,
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.EncodedAny),
33
42
  })
34
43
 
35
44
  export type PushReq = typeof PushReq.Type