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

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 +150 -129
  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 +215 -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.11",
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.11",
18
+ "@livestore/utils": "0.3.0-dev.11"
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,277 @@ 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
+ const WebSocketAttachmentSchema = Schema.parseJson(
33
+ Schema.Struct({
34
+ storeId: Schema.String,
35
+ }),
36
+ )
37
+
38
+ /**
39
+ * Needs to be bumped when the storage format changes (e.g. mutationLogTable schema changes)
40
+ *
41
+ * Changing this version number will lead to a "soft reset".
42
+ */
43
+ export const PERSISTENCE_FORMAT_VERSION = 2
44
+
45
+ export type MakeDurableObjectClassOptions = {
46
+ onPush?: (message: WSMessage.PushReq) => Effect.Effect<void> | Promise<void>
47
+ onPull?: (message: WSMessage.PullReq) => Effect.Effect<void> | Promise<void>
48
+ }
36
49
 
37
- constructor(ctx: DurableObjectState, env: Env) {
38
- super(ctx, env)
39
- }
50
+ export type MakeDurableObjectClass = (options?: MakeDurableObjectClassOptions) => {
51
+ new (ctx: DurableObjectState, env: Env): DurableObject<Env>
52
+ }
40
53
 
41
- fetch = async (_request: Request) =>
42
- Effect.gen(this, function* () {
43
- const { 0: client, 1: server } = new WebSocketPair()
54
+ export const makeDurableObject: MakeDurableObjectClass = (options) => {
55
+ return class WebSocketServerBase extends DurableObject<Env> {
56
+ constructor(ctx: DurableObjectState, env: Env) {
57
+ super(ctx, env)
58
+ }
44
59
 
45
- // See https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server
60
+ fetch = async (request: Request) =>
61
+ Effect.gen(this, function* () {
62
+ const storeId = getStoreId(request)
63
+ const storage = makeStorage(this.ctx, this.env, storeId)
46
64
 
47
- this.ctx.acceptWebSocket(server)
65
+ const { 0: client, 1: server } = new WebSocketPair()
48
66
 
49
- this.ctx.setWebSocketAutoResponse(
50
- new WebSocketRequestResponsePair(
51
- encodeIncomingMessage(WSMessage.Ping.make({ requestId: 'ping' })),
52
- encodeOutgoingMessage(WSMessage.Pong.make({ requestId: 'ping' })),
53
- ),
54
- )
67
+ // Since we're using websocket hibernation, we need to remember the storeId for subsequent `webSocketMessage` calls
68
+ server.serializeAttachment(Schema.encodeSync(WebSocketAttachmentSchema)({ storeId }))
55
69
 
56
- const colSpec = makeColumnSpec(mutationLogTable.sqliteDef.ast)
57
- this.env.DB.exec(`CREATE TABLE IF NOT EXISTS ${this.dbName} (${colSpec}) strict`)
70
+ // See https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server
58
71
 
59
- return new Response(null, {
60
- status: 101,
61
- webSocket: client,
62
- })
63
- }).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
72
+ this.ctx.acceptWebSocket(server)
64
73
 
65
- webSocketMessage = (ws: WebSocketClient, message: ArrayBuffer | string) =>
66
- Effect.gen(this, function* () {
67
- const decodedMessageRes = decodeIncomingMessage(message)
74
+ this.ctx.setWebSocketAutoResponse(
75
+ new WebSocketRequestResponsePair(
76
+ encodeIncomingMessage(WSMessage.Ping.make({ requestId: 'ping' })),
77
+ encodeOutgoingMessage(WSMessage.Pong.make({ requestId: 'ping' })),
78
+ ),
79
+ )
68
80
 
69
- if (decodedMessageRes._tag === 'Left') {
70
- console.error('Invalid message received', decodedMessageRes.left)
71
- return
72
- }
81
+ const colSpec = makeColumnSpec(mutationLogTable.sqliteDef.ast)
82
+ this.env.DB.exec(`CREATE TABLE IF NOT EXISTS ${storage.dbName} (${colSpec}) strict`)
73
83
 
74
- const decodedMessage = decodedMessageRes.right
75
- const requestId = decodedMessage.requestId
84
+ return new Response(null, {
85
+ status: 101,
86
+ webSocket: client,
87
+ })
88
+ }).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
76
89
 
77
- try {
78
- switch (decodedMessage._tag) {
79
- case 'WSMessage.PullReq': {
80
- const cursor = decodedMessage.cursor
81
- const CHUNK_SIZE = 100
90
+ webSocketMessage = (ws: WebSocketClient, message: ArrayBuffer | string) =>
91
+ Effect.gen(this, function* () {
92
+ const decodedMessageRes = decodeIncomingMessage(message)
82
93
 
83
- // TODO use streaming
84
- const remainingEvents = [...(yield* Effect.promise(() => this.storage.getEvents(cursor)))]
94
+ if (decodedMessageRes._tag === 'Left') {
95
+ console.error('Invalid message received', decodedMessageRes.left)
96
+ return
97
+ }
85
98
 
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)
99
+ const { storeId } = yield* Schema.decode(WebSocketAttachmentSchema)(ws.deserializeAttachment())
100
+ const storage = makeStorage(this.ctx, this.env, storeId)
89
101
 
90
- ws.send(
91
- encodeOutgoingMessage(WSMessage.PullRes.make({ events, remaining: remainingEvents.length, requestId })),
92
- )
102
+ const decodedMessage = decodedMessageRes.right
103
+ const requestId = decodedMessage.requestId
93
104
 
94
- if (remainingEvents.length === 0) {
95
- break
105
+ try {
106
+ switch (decodedMessage._tag) {
107
+ case 'WSMessage.PullReq': {
108
+ if (options?.onPull) {
109
+ yield* Effect.tryAll(() => options.onPull!(decodedMessage))
96
110
  }
97
- }
98
111
 
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
112
+ const cursor = decodedMessage.cursor
113
+ const CHUNK_SIZE = 100
114
+
115
+ // TODO use streaming
116
+ const remainingEvents = [...(yield* Effect.promise(() => storage.getEvents(cursor)))]
117
+
118
+ // 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)
121
+
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
+ }
118
131
  }
119
132
 
120
- // TODO handle clientId unique conflict
133
+ break
134
+ }
135
+ case 'WSMessage.PushReq': {
136
+ if (options?.onPush) {
137
+ yield* Effect.tryAll(() => options.onPush!(decodedMessage))
138
+ }
121
139
 
122
- const createdAt = new Date().toISOString()
140
+ // 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
123
143
 
124
- // NOTE we're currently not blocking on this to allow broadcasting right away
125
- const storePromise = this.storage.appendEvent(mutationEventEncoded, createdAt)
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
+ })
126
151
 
127
- ws.send(
128
- encodeOutgoingMessage(
129
- WSMessage.PushAck.make({ mutationId: mutationEventEncoded.id.global, requestId }),
130
- ),
131
- )
152
+ yield* Effect.fail(err).pipe(Effect.ignoreLogged)
153
+
154
+ ws.send(encodeOutgoingMessage(err))
155
+ return
156
+ }
157
+
158
+ // TODO handle clientId unique conflict
132
159
 
133
- // console.debug(`Broadcasting mutation event to ${this.subscribedWebSockets.size} clients`)
160
+ const createdAt = new Date().toISOString()
134
161
 
135
- const connectedClients = this.ctx.getWebSockets()
162
+ // NOTE we're currently not blocking on this to allow broadcasting right away
163
+ const storePromise = storage.appendEvent(mutationEventEncoded, createdAt)
136
164
 
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
- }),
165
+ ws.send(
166
+ encodeOutgoingMessage(WSMessage.PushAck.make({ mutationId: mutationEventEncoded.id, requestId })),
144
167
  )
145
168
 
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
- // }
169
+ // console.debug(`Broadcasting mutation event to ${this.subscribedWebSockets.size} clients`)
170
+
171
+ const connectedClients = this.ctx.getWebSockets()
172
+
173
+ if (connectedClients.length > 0) {
174
+ const broadcastMessage = encodeOutgoingMessage(
175
+ // TODO refactor to batch api
176
+ WSMessage.PushBroadcast.make({
177
+ mutationEventEncoded,
178
+ 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
+ }
151
188
  }
152
- }
153
189
 
154
- yield* Effect.promise(() => storePromise)
190
+ yield* Effect.promise(() => storePromise)
155
191
 
156
- i++
157
- }
192
+ i++
193
+ }
158
194
 
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
195
+ break
165
196
  }
197
+ case 'WSMessage.AdminResetRoomReq': {
198
+ if (decodedMessage.adminSecret !== this.env.ADMIN_SECRET) {
199
+ ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: 'Invalid admin secret', requestId })))
200
+ return
201
+ }
166
202
 
167
- yield* Effect.promise(() => this.storage.resetRoom())
168
- ws.send(encodeOutgoingMessage(WSMessage.AdminResetRoomRes.make({ requestId })))
203
+ yield* Effect.promise(() => storage.resetRoom())
204
+ ws.send(encodeOutgoingMessage(WSMessage.AdminResetRoomRes.make({ requestId })))
169
205
 
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
206
+ break
176
207
  }
208
+ case 'WSMessage.AdminInfoReq': {
209
+ if (decodedMessage.adminSecret !== this.env.ADMIN_SECRET) {
210
+ ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: 'Invalid admin secret', requestId })))
211
+ return
212
+ }
177
213
 
178
- ws.send(
179
- encodeOutgoingMessage(
180
- WSMessage.AdminInfoRes.make({ requestId, info: { durableObjectId: this.ctx.id.toString() } }),
181
- ),
182
- )
214
+ ws.send(
215
+ encodeOutgoingMessage(
216
+ WSMessage.AdminInfoRes.make({ requestId, info: { durableObjectId: this.ctx.id.toString() } }),
217
+ ),
218
+ )
183
219
 
184
- break
185
- }
186
- default: {
187
- console.error('unsupported message', decodedMessage)
188
- return shouldNeverHappen()
220
+ break
221
+ }
222
+ default: {
223
+ console.error('unsupported message', decodedMessage)
224
+ return shouldNeverHappen()
225
+ }
189
226
  }
227
+ } catch (error: any) {
228
+ ws.send(encodeOutgoingMessage(WSMessage.Error.make({ message: error.message, requestId })))
190
229
  }
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
- )
230
+ }).pipe(
231
+ Effect.withSpan('@livestore/sync-cf:durable-object:webSocketMessage'),
232
+ Effect.tapCauseLogPretty,
233
+ Logger.withMinimumLogLevel(LogLevel.Debug),
234
+ Effect.provide(Logger.pretty),
235
+ Effect.runPromise,
236
+ )
201
237
 
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')
238
+ webSocketClose = async (ws: WebSocketClient, code: number, _reason: string, _wasClean: boolean) => {
239
+ // If the client closes the connection, the runtime will invoke the webSocketClose() handler.
240
+ ws.close(code, 'Durable Object is closing WebSocket')
241
+ }
205
242
  }
206
243
  }
207
244
 
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()
245
+ type SyncStorage = {
246
+ dbName: string
247
+ getLatestEvent: () => Promise<MutationEvent.AnyEncodedGlobal | undefined>
248
+ getEvents: (
249
+ cursor: number | undefined,
250
+ ) => Promise<
251
+ ReadonlyArray<{ mutationEventEncoded: MutationEvent.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> }>
252
+ >
253
+ appendEvent: (event: MutationEvent.AnyEncodedGlobal, createdAt: string) => Promise<void>
254
+ resetRoom: () => Promise<void>
255
+ }
256
+
257
+ const makeStorage = (ctx: DurableObjectState, env: Env, storeId: string): SyncStorage => {
258
+ const dbName = `mutation_log_${PERSISTENCE_FORMAT_VERSION}_${toValidTableName(storeId)}`
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()
211
262
  if (rawEvents.error) {
212
263
  throw new Error(rawEvents.error)
213
264
  }
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
- }))
265
+ const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results)
266
+
220
267
  return events[0]
221
268
  }
222
269
 
223
270
  const getEvents = async (
224
271
  cursor: number | undefined,
225
272
  ): Promise<
226
- ReadonlyArray<{ mutationEventEncoded: MutationEvent.AnyEncoded; metadata: Option.Option<SyncMetadata> }>
273
+ ReadonlyArray<{ mutationEventEncoded: MutationEvent.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> }>
227
274
  > => {
228
- const whereClause = cursor === undefined ? '' : `WHERE idGlobal > ${cursor}`
229
- const sql = `SELECT * FROM ${dbName} ${whereClause} ORDER BY idGlobal ASC`
275
+ const whereClause = cursor === undefined ? '' : `WHERE id > ${cursor}`
276
+ const sql = `SELECT * FROM ${dbName} ${whereClause} ORDER BY id ASC`
230
277
  // TODO handle case where `cursor` was not found
231
278
  const rawEvents = await env.DB.prepare(sql).all()
232
279
  if (rawEvents.error) {
233
280
  throw new Error(rawEvents.error)
234
281
  }
235
282
  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
- },
283
+ ({ createdAt, ...mutationEventEncoded }) => ({
284
+ mutationEventEncoded,
243
285
  metadata: Option.some({ createdAt }),
244
286
  }),
245
287
  )
246
288
  return events
247
289
  }
248
290
 
249
- const appendEvent = async (event: MutationEvent.Any, createdAt: string) => {
250
- const sql = `INSERT INTO ${dbName} (idGlobal, parentIdGlobal, args, mutation, createdAt) VALUES (?, ?, ?, ?, ?)`
291
+ const appendEvent = async (event: MutationEvent.AnyEncodedGlobal, createdAt: string) => {
292
+ const sql = `INSERT INTO ${dbName} (id, parentId, args, mutation, createdAt) VALUES (?, ?, ?, ?, ?)`
251
293
  await env.DB.prepare(sql)
252
- .bind(event.id.global, event.parentId.global, JSON.stringify(event.args), event.mutation, createdAt)
294
+ .bind(event.id, event.parentId, JSON.stringify(event.args), event.mutation, createdAt)
253
295
  .run()
254
296
  }
255
297
 
@@ -257,5 +299,17 @@ const makeStorage = (ctx: DurableObjectState, env: Env, dbName: string) => {
257
299
  await ctx.storage.deleteAll()
258
300
  }
259
301
 
260
- return { getLatestEvent, getEvents, appendEvent, resetRoom }
302
+ return { dbName, getLatestEvent, getEvents, appendEvent, resetRoom }
303
+ }
304
+
305
+ const getStoreId = (request: Request) => {
306
+ const url = new URL(request.url)
307
+ const searchParams = url.searchParams
308
+ const storeId = searchParams.get('storeId')
309
+ if (storeId === null) {
310
+ throw new Error('storeId search param is required')
311
+ }
312
+ return storeId
261
313
  }
314
+
315
+ 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'