@livestore/sync-cf 0.3.0-dev.1 → 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 (50) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/cf-worker/durable-object.d.ts +27 -34
  3. package/dist/cf-worker/durable-object.d.ts.map +1 -1
  4. package/dist/cf-worker/durable-object.js +149 -128
  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 +54 -207
  31. package/dist/common/ws-message-types.d.ts.map +1 -1
  32. package/dist/common/ws-message-types.js +3 -3
  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 +3 -12
  39. package/dist/sync-impl/ws-impl.d.ts.map +1 -1
  40. package/dist/sync-impl/ws-impl.js +5 -4
  41. package/dist/sync-impl/ws-impl.js.map +1 -1
  42. package/package.json +15 -13
  43. package/src/cf-worker/durable-object.ts +212 -161
  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 +3 -3
  47. package/src/sync-impl/ws-impl.ts +8 -16
  48. package/src/cf-worker/index.ts +0 -84
  49. /package/src/common/{index.ts → mod.ts} +0 -0
  50. /package/src/sync-impl/{index.ts → mod.ts} +0 -0
package/package.json CHANGED
@@ -1,33 +1,35 @@
1
1
  {
2
2
  "name": "@livestore/sync-cf",
3
- "version": "0.3.0-dev.1",
3
+ "version": "0.3.0-dev.10",
4
4
  "type": "module",
5
+ "sideEffects": false,
5
6
  "exports": {
6
7
  ".": {
7
- "types": "./dist/sync-impl/index.d.ts",
8
- "default": "./dist/sync-impl/index.js"
8
+ "types": "./dist/sync-impl/mod.d.ts",
9
+ "default": "./dist/sync-impl/mod.js"
10
+ },
11
+ "./cf-worker": {
12
+ "types": "./dist/cf-worker/mod.d.ts",
13
+ "default": "./dist/cf-worker/mod.js"
9
14
  }
10
15
  },
16
+ "dependencies": {
17
+ "@livestore/common": "0.3.0-dev.10",
18
+ "@livestore/utils": "0.3.0-dev.10"
19
+ },
20
+ "devDependencies": {
21
+ "@cloudflare/workers-types": "^4.20241022.0"
22
+ },
11
23
  "files": [
12
24
  "dist",
13
25
  "src",
14
26
  "package.json",
15
27
  "README.md"
16
28
  ],
17
- "dependencies": {
18
- "@livestore/common": "0.3.0-dev.1",
19
- "@livestore/utils": "0.3.0-dev.1"
20
- },
21
- "devDependencies": {
22
- "@cloudflare/workers-types": "4.20241022.0",
23
- "wrangler": "^3.84.0"
24
- },
25
29
  "publishConfig": {
26
30
  "access": "public"
27
31
  },
28
32
  "scripts": {
29
- "dev": "wrangler dev --inspector-port 9230",
30
- "deploy": "wrangler publish",
31
33
  "test": "echo 'No tests yet'"
32
34
  }
33
35
  }
@@ -4,11 +4,11 @@ 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
  }
@@ -21,235 +21,274 @@ const decodeIncomingMessage = Schema.decodeUnknownEither(Schema.parseJson(WSMess
21
21
 
22
22
  // NOTE actual table name is determined at runtime by `WebSocketServer.dbName`
23
23
  export const mutationLogTable = DbSchema.table('__unused', {
24
- idGlobal: DbSchema.integer({ primaryKey: true }),
25
- parentIdGlobal: DbSchema.integer({}),
24
+ id: DbSchema.integer({ primaryKey: true, schema: EventId.GlobalEventId }),
25
+ parentId: DbSchema.integer({ schema: EventId.GlobalEventId }),
26
26
  mutation: DbSchema.text({}),
27
27
  args: DbSchema.text({ schema: Schema.parseJson(Schema.Any) }),
28
- /** ISO date format */
28
+ /** ISO date format. Currently only used for debugging purposes. */
29
29
  createdAt: DbSchema.text({}),
30
30
  })
31
31
 
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)
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
36
38
 
37
- constructor(ctx: DurableObjectState, env: Env) {
38
- super(ctx, env)
39
- }
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
+ }
40
43
 
41
- fetch = async (_request: Request) =>
42
- Effect.gen(this, function* () {
43
- const { 0: client, 1: server } = new WebSocketPair()
44
+ export type MakeDurableObjectClass = (options?: MakeDurableObjectClassOptions) => {
45
+ new (ctx: DurableObjectState, env: Env): DurableObject<Env>
46
+ }
44
47
 
45
- // 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
46
51
 
47
- this.ctx.acceptWebSocket(server)
52
+ constructor(ctx: DurableObjectState, env: Env) {
53
+ super(ctx, env)
54
+ }
48
55
 
49
- this.ctx.setWebSocketAutoResponse(
50
- new WebSocketRequestResponsePair(
51
- encodeIncomingMessage(WSMessage.Ping.make({ requestId: 'ping' })),
52
- encodeOutgoingMessage(WSMessage.Pong.make({ requestId: 'ping' })),
53
- ),
54
- )
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
+ }
55
63
 
56
- const colSpec = makeColumnSpec(mutationLogTable.sqliteDef.ast)
57
- this.env.DB.exec(`CREATE TABLE IF NOT EXISTS ${this.dbName} (${colSpec}) strict`)
64
+ const { 0: client, 1: server } = new WebSocketPair()
58
65
 
59
- return new Response(null, {
60
- status: 101,
61
- webSocket: client,
62
- })
63
- }).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
66
+ // See https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server
64
67
 
65
- webSocketMessage = (ws: WebSocketClient, message: ArrayBuffer | string) =>
66
- Effect.gen(this, function* () {
67
- const decodedMessageRes = decodeIncomingMessage(message)
68
+ this.ctx.acceptWebSocket(server)
68
69
 
69
- if (decodedMessageRes._tag === 'Left') {
70
- console.error('Invalid message received', decodedMessageRes.left)
71
- return
72
- }
70
+ this.ctx.setWebSocketAutoResponse(
71
+ new WebSocketRequestResponsePair(
72
+ encodeIncomingMessage(WSMessage.Ping.make({ requestId: 'ping' })),
73
+ encodeOutgoingMessage(WSMessage.Pong.make({ requestId: 'ping' })),
74
+ ),
75
+ )
73
76
 
74
- const decodedMessage = decodedMessageRes.right
75
- 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`)
76
79
 
77
- try {
78
- switch (decodedMessage._tag) {
79
- case 'WSMessage.PullReq': {
80
- const cursor = decodedMessage.cursor
81
- const CHUNK_SIZE = 100
80
+ return new Response(null, {
81
+ status: 101,
82
+ webSocket: client,
83
+ })
84
+ }).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
82
85
 
83
- // TODO use streaming
84
- const remainingEvents = [...(yield* Effect.promise(() => this.storage.getEvents(cursor)))]
86
+ webSocketMessage = (ws: WebSocketClient, message: ArrayBuffer | string) =>
87
+ Effect.gen(this, function* () {
88
+ const decodedMessageRes = decodeIncomingMessage(message)
85
89
 
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)
90
+ if (decodedMessageRes._tag === 'Left') {
91
+ console.error('Invalid message received', decodedMessageRes.left)
92
+ return
93
+ }
89
94
 
90
- ws.send(
91
- encodeOutgoingMessage(WSMessage.PullRes.make({ events, remaining: remainingEvents.length, requestId })),
92
- )
95
+ const decodedMessage = decodedMessageRes.right
96
+ const requestId = decodedMessage.requestId
97
+
98
+ const storage = this.storage
99
+
100
+ if (storage === undefined) {
101
+ throw new Error('storage not initialized')
102
+ }
93
103
 
94
- if (remainingEvents.length === 0) {
95
- break
104
+ try {
105
+ switch (decodedMessage._tag) {
106
+ case 'WSMessage.PullReq': {
107
+ if (options?.onPull) {
108
+ yield* Effect.tryAll(() => options.onPull!(decodedMessage))
96
109
  }
97
- }
98
110
 
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) {
109
- const err = WSMessage.Error.make({
110
- message: `Invalid parent id. Received ${mutationEventEncoded.parentId.global} but expected ${expectedParentId.global}`,
111
- requestId,
112
- })
113
-
114
- yield* Effect.fail(err).pipe(Effect.ignoreLogged)
115
-
116
- ws.send(encodeOutgoingMessage(err))
117
- return
111
+ const cursor = decodedMessage.cursor
112
+ const CHUNK_SIZE = 100
113
+
114
+ // 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
+ }
130
+ }
131
+
132
+ break
133
+ }
134
+ case 'WSMessage.PushReq': {
135
+ if (options?.onPush) {
136
+ yield* Effect.tryAll(() => options.onPush!(decodedMessage))
118
137
  }
119
138
 
120
- // TODO handle clientId unique conflict
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
121
142
 
122
- const createdAt = new Date().toISOString()
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
+ })
123
150
 
124
- // NOTE we're currently not blocking on this to allow broadcasting right away
125
- const storePromise = this.storage.appendEvent(mutationEventEncoded, createdAt)
151
+ yield* Effect.fail(err).pipe(Effect.ignoreLogged)
126
152
 
127
- ws.send(
128
- encodeOutgoingMessage(
129
- WSMessage.PushAck.make({ mutationId: mutationEventEncoded.id.global, requestId }),
130
- ),
131
- )
153
+ ws.send(encodeOutgoingMessage(err))
154
+ return
155
+ }
132
156
 
133
- // console.debug(`Broadcasting mutation event to ${this.subscribedWebSockets.size} clients`)
157
+ // TODO handle clientId unique conflict
134
158
 
135
- const connectedClients = this.ctx.getWebSockets()
159
+ const createdAt = new Date().toISOString()
136
160
 
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
- }),
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 })),
144
166
  )
145
167
 
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
- // }
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
+ }
151
187
  }
152
- }
153
188
 
154
- yield* Effect.promise(() => storePromise)
189
+ yield* Effect.promise(() => storePromise)
155
190
 
156
- i++
157
- }
191
+ i++
192
+ }
158
193
 
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
194
+ break
165
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
+ }
166
201
 
167
- yield* Effect.promise(() => this.storage.resetRoom())
168
- ws.send(encodeOutgoingMessage(WSMessage.AdminResetRoomRes.make({ requestId })))
202
+ yield* Effect.promise(() => storage.resetRoom())
203
+ ws.send(encodeOutgoingMessage(WSMessage.AdminResetRoomRes.make({ requestId })))
169
204
 
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
205
+ break
176
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
+ }
177
212
 
178
- ws.send(
179
- encodeOutgoingMessage(
180
- WSMessage.AdminInfoRes.make({ requestId, info: { durableObjectId: this.ctx.id.toString() } }),
181
- ),
182
- )
213
+ ws.send(
214
+ encodeOutgoingMessage(
215
+ WSMessage.AdminInfoRes.make({ requestId, info: { durableObjectId: this.ctx.id.toString() } }),
216
+ ),
217
+ )
183
218
 
184
- break
185
- }
186
- default: {
187
- console.error('unsupported message', decodedMessage)
188
- return shouldNeverHappen()
219
+ break
220
+ }
221
+ default: {
222
+ console.error('unsupported message', decodedMessage)
223
+ return shouldNeverHappen()
224
+ }
189
225
  }
226
+ } catch (error: any) {
227
+ ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: error.message, requestId })))
190
228
  }
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
- )
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
+ )
201
236
 
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')
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')
240
+ }
205
241
  }
206
242
  }
207
243
 
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()
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>
254
+ }
255
+
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()
211
259
  if (rawEvents.error) {
212
260
  throw new Error(rawEvents.error)
213
261
  }
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
- }))
262
+ const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results)
263
+
220
264
  return events[0]
221
265
  }
222
266
 
223
267
  const getEvents = async (
224
268
  cursor: number | undefined,
225
269
  ): Promise<
226
- ReadonlyArray<{ mutationEventEncoded: MutationEvent.AnyEncoded; metadata: Option.Option<SyncMetadata> }>
270
+ ReadonlyArray<{ mutationEventEncoded: MutationEvent.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> }>
227
271
  > => {
228
- const whereClause = cursor === undefined ? '' : `WHERE idGlobal > ${cursor}`
229
- const sql = `SELECT * FROM ${dbName} ${whereClause} ORDER BY idGlobal ASC`
272
+ const whereClause = cursor === undefined ? '' : `WHERE id > ${cursor}`
273
+ const sql = `SELECT * FROM ${dbName} ${whereClause} ORDER BY id ASC`
230
274
  // TODO handle case where `cursor` was not found
231
275
  const rawEvents = await env.DB.prepare(sql).all()
232
276
  if (rawEvents.error) {
233
277
  throw new Error(rawEvents.error)
234
278
  }
235
279
  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
- },
280
+ ({ createdAt, ...mutationEventEncoded }) => ({
281
+ mutationEventEncoded,
243
282
  metadata: Option.some({ createdAt }),
244
283
  }),
245
284
  )
246
285
  return events
247
286
  }
248
287
 
249
- const appendEvent = async (event: MutationEvent.Any, createdAt: string) => {
250
- const sql = `INSERT INTO ${dbName} (idGlobal, parentIdGlobal, args, mutation, createdAt) VALUES (?, ?, ?, ?, ?)`
288
+ const appendEvent = async (event: MutationEvent.AnyEncodedGlobal, createdAt: string) => {
289
+ const sql = `INSERT INTO ${dbName} (id, parentId, args, mutation, createdAt) VALUES (?, ?, ?, ?, ?)`
251
290
  await env.DB.prepare(sql)
252
- .bind(event.id.global, event.parentId.global, JSON.stringify(event.args), event.mutation, createdAt)
291
+ .bind(event.id, event.parentId, JSON.stringify(event.args), event.mutation, createdAt)
253
292
  .run()
254
293
  }
255
294
 
@@ -257,5 +296,17 @@ const makeStorage = (ctx: DurableObjectState, env: Env, dbName: string) => {
257
296
  await ctx.storage.deleteAll()
258
297
  }
259
298
 
260
- return { getLatestEvent, getEvents, appendEvent, resetRoom }
299
+ return { dbName, getLatestEvent, getEvents, appendEvent, resetRoom }
261
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
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
+ }
@@ -20,7 +20,7 @@ export const PullRes = Schema.TaggedStruct('WSMessage.PullRes', {
20
20
  requestId: Schema.String,
21
21
  events: Schema.Array(
22
22
  Schema.Struct({
23
- mutationEventEncoded: MutationEvent.EncodedAny,
23
+ mutationEventEncoded: MutationEvent.AnyEncodedGlobal,
24
24
  metadata: Schema.Option(SyncMetadata),
25
25
  }),
26
26
  ),
@@ -30,7 +30,7 @@ export const PullRes = Schema.TaggedStruct('WSMessage.PullRes', {
30
30
  export type PullRes = typeof PullRes.Type
31
31
 
32
32
  export const PushBroadcast = Schema.TaggedStruct('WSMessage.PushBroadcast', {
33
- mutationEventEncoded: MutationEvent.EncodedAny,
33
+ mutationEventEncoded: MutationEvent.AnyEncodedGlobal,
34
34
  metadata: Schema.Option(SyncMetadata),
35
35
  })
36
36
 
@@ -38,7 +38,7 @@ export type PushBroadcast = typeof PushBroadcast.Type
38
38
 
39
39
  export const PushReq = Schema.TaggedStruct('WSMessage.PushReq', {
40
40
  requestId: Schema.String,
41
- batch: Schema.Array(MutationEvent.EncodedAny),
41
+ batch: Schema.Array(MutationEvent.AnyEncodedGlobal),
42
42
  })
43
43
 
44
44
  export type PushReq = typeof PushReq.Type
@@ -1,6 +1,6 @@
1
1
  /// <reference lib="dom" />
2
2
 
3
- import type { SyncBackend, SyncBackendOptionsBase } from '@livestore/common'
3
+ import type { SyncBackend } from '@livestore/common'
4
4
  import { InvalidPullError, InvalidPushError } from '@livestore/common'
5
5
  import { pick } from '@livestore/utils'
6
6
  import type { Scope } from '@livestore/utils/effect'
@@ -18,26 +18,18 @@ import {
18
18
  } from '@livestore/utils/effect'
19
19
  import { nanoid } from '@livestore/utils/nanoid'
20
20
 
21
- import { WSMessage } from '../common/index.js'
21
+ import { WSMessage } from '../common/mod.js'
22
22
  import type { SyncMetadata } from '../common/ws-message-types.js'
23
23
 
24
- export interface WsSyncOptions extends SyncBackendOptionsBase {
25
- type: 'cf'
24
+ export interface WsSyncOptions {
26
25
  url: string
27
- roomId: string
28
- }
29
-
30
- interface LiveStoreGlobalCf {
31
- syncBackend: WsSyncOptions
32
- }
33
-
34
- declare global {
35
- interface LiveStoreGlobal extends LiveStoreGlobalCf {}
26
+ storeId: string
36
27
  }
37
28
 
38
29
  export const makeWsSync = (options: WsSyncOptions): Effect.Effect<SyncBackend<SyncMetadata>, never, Scope.Scope> =>
39
30
  Effect.gen(function* () {
40
- const wsUrl = `${options.url}/websocket?room=${options.roomId}`
31
+ // TODO also allow for auth scenarios
32
+ const wsUrl = `${options.url}/websocket?storeId=${options.storeId}`
41
33
 
42
34
  const { isConnected, incomingMessages, send } = yield* connect(wsUrl)
43
35
 
@@ -127,8 +119,8 @@ const connect = (wsUrl: string) =>
127
119
  `Sending message: ${message._tag}`,
128
120
  message._tag === 'WSMessage.PushReq'
129
121
  ? {
130
- id: message.batch[0]!.id.global,
131
- parentId: message.batch[0]!.parentId.global,
122
+ id: message.batch[0]!.id,
123
+ parentId: message.batch[0]!.parentId,
132
124
  batchLength: message.batch.length,
133
125
  }
134
126
  : message._tag === 'WSMessage.PullReq'