orez 0.2.24 → 0.2.26

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 (138) hide show
  1. package/dist/cf-do/test-protocol.d.ts +11 -0
  2. package/dist/cf-do/test-protocol.d.ts.map +1 -0
  3. package/dist/cf-do/test-protocol.js +137 -0
  4. package/dist/cf-do/test-protocol.js.map +1 -0
  5. package/dist/cf-do/watermark.d.ts +21 -0
  6. package/dist/cf-do/watermark.d.ts.map +1 -0
  7. package/dist/cf-do/watermark.js +93 -0
  8. package/dist/cf-do/watermark.js.map +1 -0
  9. package/dist/cf-do/worker.d.ts +91 -0
  10. package/dist/cf-do/worker.d.ts.map +1 -0
  11. package/dist/cf-do/worker.js +813 -0
  12. package/dist/cf-do/worker.js.map +1 -0
  13. package/dist/config.d.ts +4 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +1 -0
  16. package/dist/config.js.map +1 -1
  17. package/dist/do-sql-tracking.d.ts +6 -0
  18. package/dist/do-sql-tracking.d.ts.map +1 -0
  19. package/dist/do-sql-tracking.js +14 -0
  20. package/dist/do-sql-tracking.js.map +1 -0
  21. package/dist/index.d.ts +2 -3
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +69 -23
  24. package/dist/index.js.map +1 -1
  25. package/dist/pg-proxy-browser.js +6 -6
  26. package/dist/pg-proxy-browser.js.map +1 -1
  27. package/dist/pg-proxy-do-backend.d.ts +128 -0
  28. package/dist/pg-proxy-do-backend.d.ts.map +1 -0
  29. package/dist/pg-proxy-do-backend.js +6292 -0
  30. package/dist/pg-proxy-do-backend.js.map +1 -0
  31. package/dist/pglite-ipc.d.ts +3 -0
  32. package/dist/pglite-ipc.d.ts.map +1 -1
  33. package/dist/pglite-ipc.js +34 -12
  34. package/dist/pglite-ipc.js.map +1 -1
  35. package/dist/pglite-web-proxy.d.ts +3 -0
  36. package/dist/pglite-web-proxy.d.ts.map +1 -1
  37. package/dist/pglite-web-proxy.js +50 -7
  38. package/dist/pglite-web-proxy.js.map +1 -1
  39. package/dist/query-rewrites.d.ts +2 -0
  40. package/dist/query-rewrites.d.ts.map +1 -0
  41. package/dist/query-rewrites.js +140 -0
  42. package/dist/query-rewrites.js.map +1 -0
  43. package/dist/replication/change-tracker.d.ts.map +1 -1
  44. package/dist/replication/change-tracker.js +18 -1
  45. package/dist/replication/change-tracker.js.map +1 -1
  46. package/dist/replication/handler.d.ts.map +1 -1
  47. package/dist/replication/handler.js +7 -2
  48. package/dist/replication/handler.js.map +1 -1
  49. package/dist/replication/pgoutput-encoder.d.ts.map +1 -1
  50. package/dist/replication/pgoutput-encoder.js +72 -30
  51. package/dist/replication/pgoutput-encoder.js.map +1 -1
  52. package/dist/worker/browser-build-config.d.ts.map +1 -1
  53. package/dist/worker/browser-build-config.js +2 -1
  54. package/dist/worker/browser-build-config.js.map +1 -1
  55. package/dist/worker/cf-patches.d.ts +5 -2
  56. package/dist/worker/cf-patches.d.ts.map +1 -1
  57. package/dist/worker/cf-patches.js +238 -4
  58. package/dist/worker/cf-patches.js.map +1 -1
  59. package/dist/worker/shims/node-stub.d.ts +35 -0
  60. package/dist/worker/shims/node-stub.d.ts.map +1 -1
  61. package/dist/worker/shims/node-stub.js +53 -1
  62. package/dist/worker/shims/node-stub.js.map +1 -1
  63. package/dist/worker/shims/oxfmt.d.ts +4 -0
  64. package/dist/worker/shims/oxfmt.d.ts.map +1 -0
  65. package/dist/worker/shims/oxfmt.js +4 -0
  66. package/dist/worker/shims/oxfmt.js.map +1 -0
  67. package/dist/worker/shims/postgres-socket.js +1 -1
  68. package/dist/worker/shims/postgres-socket.js.map +1 -1
  69. package/dist/worker/shims/sqlite.d.ts +1 -0
  70. package/dist/worker/shims/sqlite.d.ts.map +1 -1
  71. package/dist/worker/shims/sqlite.js +229 -9
  72. package/dist/worker/shims/sqlite.js.map +1 -1
  73. package/dist/worker/shims/ws.d.ts.map +1 -1
  74. package/dist/worker/shims/ws.js +45 -0
  75. package/dist/worker/shims/ws.js.map +1 -1
  76. package/dist/worker/shims/zero-process-env.d.ts +2 -0
  77. package/dist/worker/shims/zero-process-env.d.ts.map +1 -0
  78. package/dist/worker/shims/zero-process-env.js +9 -0
  79. package/dist/worker/shims/zero-process-env.js.map +1 -0
  80. package/dist/worker/zero-cache-embed-cf.d.ts +29 -12
  81. package/dist/worker/zero-cache-embed-cf.d.ts.map +1 -1
  82. package/dist/worker/zero-cache-embed-cf.js +83 -14
  83. package/dist/worker/zero-cache-embed-cf.js.map +1 -1
  84. package/package.json +6 -2
  85. package/src/cf-do/.wrangler/cache/cf.json +1 -0
  86. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
  87. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
  88. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
  89. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/0f0f3bdf0abda097eb6f1246db4657d9fc622081362d894d82c1a1ce067b05b6.sqlite +0 -0
  90. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/1ddd3a4a48a11b51658444f5458a1fb175194b1d5b6a5bda20ef3fe3205b900c.sqlite +0 -0
  91. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/204a39120310d37e972c5914cfd71ad55c151bdb9e8ed289a5f8c5b052dd60e4.sqlite +0 -0
  92. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/3835f242df9728adba3d127a238793fd054ed3e51df3f60749ee744c469bf2a2.sqlite +0 -0
  93. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/4aa9c80eb716cf55b8995ccf7afab0b36c683e6da07d7c37a3f9c570136036df.sqlite +0 -0
  94. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/533e2fd1d6ea46e7a9a0017916ef341802d438d72583462755f2c1f8225e9bf2.sqlite +0 -0
  95. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/5ffa1aced1225ecaeac6366f7586aa3de92761cdff8711d81fbd81f248076abd.sqlite +0 -0
  96. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/686c3a9f0d7e59ed2ab607efd4b76d779c97cafeb3818380033bf7c7eb86c819.sqlite +0 -0
  97. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/6e8214e8dcfadd0deb52d64e5e9ca85c6b329ace11193909845995396914c473.sqlite +0 -0
  98. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/78d9ec9ff873d3fe3507ff53c2a6f6dfc408b4268eb0db3f2a146c0678965366.sqlite +0 -0
  99. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/7eff9f0ed7e27ad0d3f9d923de0682fab1928591172c1ba336c5f79a134a5d85.sqlite +0 -0
  100. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/836cda5b995b25867d722ed4f4c2292167e80351a3c6038db626648eb247dd8b.sqlite +0 -0
  101. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/91ef63b112209ab30172763acd8a0935106c248f7f1bcae5545ce37a9f201551.sqlite +0 -0
  102. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/a66ea4293a5f5938bc6d116edfa2522bb85bc37aea3541fbc09c3b613b9b32c0.sqlite +0 -0
  103. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/ceb2ab26b80590840b65651deb6e948d3bf81565c6751f3a58752cf4bf4aecae.sqlite +0 -0
  104. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite +0 -0
  105. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-shm +0 -0
  106. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-wal +0 -0
  107. package/src/cf-do/ARCHITECTURE.md +83 -0
  108. package/src/cf-do/watermark.test.ts +103 -0
  109. package/src/cf-do/watermark.ts +118 -0
  110. package/src/cf-do/worker.ts +1033 -0
  111. package/src/cf-do/wrangler.toml +11 -0
  112. package/src/config.ts +5 -0
  113. package/src/do-sql-tracking.test.ts +19 -0
  114. package/src/do-sql-tracking.ts +19 -0
  115. package/src/index.ts +76 -28
  116. package/src/pg-proxy-browser.ts +6 -6
  117. package/src/pg-proxy-do-backend.test.ts +3890 -0
  118. package/src/pg-proxy-do-backend.ts +7157 -0
  119. package/src/pglite-ipc.test.ts +17 -0
  120. package/src/pglite-ipc.ts +31 -12
  121. package/src/pglite-web-proxy.test.ts +57 -0
  122. package/src/pglite-web-proxy.ts +48 -7
  123. package/src/replication/change-tracker.ts +16 -1
  124. package/src/replication/handler.test.ts +35 -0
  125. package/src/replication/handler.ts +7 -2
  126. package/src/replication/pgoutput-encoder.test.ts +71 -2
  127. package/src/replication/pgoutput-encoder.ts +65 -30
  128. package/src/worker/browser-build-config.test.ts +12 -0
  129. package/src/worker/browser-build-config.ts +2 -1
  130. package/src/worker/cf-patches.ts +274 -4
  131. package/src/worker/shims/node-stub.ts +53 -1
  132. package/src/worker/shims/oxfmt.ts +3 -0
  133. package/src/worker/shims/postgres-socket.ts +1 -1
  134. package/src/worker/shims/sqlite.test.ts +145 -0
  135. package/src/worker/shims/sqlite.ts +256 -9
  136. package/src/worker/shims/ws.ts +45 -0
  137. package/src/worker/shims/zero-process-env.ts +11 -0
  138. package/src/worker/zero-cache-embed-cf.ts +114 -18
@@ -0,0 +1,1033 @@
1
+ // @ts-nocheck — cloudflare:workers types not available in orez
2
+ import { DurableObject } from 'cloudflare:workers'
3
+
4
+ import { trackedChangeRow } from '../do-sql-tracking.js'
5
+ import { DurableWatermarkState } from './watermark.js'
6
+
7
+ /**
8
+ * zero-do: Durable Object that exposes raw SQL execution over ctx.storage.sql.
9
+ *
10
+ * The production Cloudflare path runs real zero-cache via
11
+ * src/worker/zero-cache-embed-cf.ts, with DoBackend calling this DO for
12
+ * Postgres-protocol-backed SQL. The WS sync handler here is kept for
13
+ * development/protocol experiments only; it is not the production replacement
14
+ * for zero-cache.
15
+ *
16
+ * Modes:
17
+ * WS /sync/v49/connect — bespoke Zero sync protocol (dev/protocol testing)
18
+ * POST /exec — raw SQL execution (from DoBackend adapter)
19
+ * POST /batch — atomic batch execution via ctx.storage.transaction()
20
+ */
21
+
22
+ interface Env {
23
+ ZERO_DO: DurableObjectNamespace
24
+ }
25
+ interface SchemaTable {
26
+ primaryKey: string[]
27
+ columns: Record<string, { type: string; optional?: boolean }>
28
+ }
29
+ interface ClientSchema {
30
+ tables: Record<string, SchemaTable>
31
+ }
32
+ interface DesiredQuery {
33
+ hash: string
34
+ tableNames: string[]
35
+ }
36
+ interface DesiredQueryPatchOp {
37
+ op: 'put' | 'del' | 'clear'
38
+ hash?: string
39
+ name?: string
40
+ ast?: any
41
+ }
42
+ interface CrudOp {
43
+ op: 'insert' | 'update' | 'upsert' | 'delete'
44
+ tableName: string
45
+ value?: Record<string, unknown>
46
+ primaryKey?: string[]
47
+ }
48
+ interface PushMutation {
49
+ type: string
50
+ name: string
51
+ clientID: string
52
+ id: number
53
+ args: unknown[]
54
+ }
55
+ interface PushBody {
56
+ clientGroupID?: string
57
+ mutations: PushMutation[]
58
+ }
59
+ interface SqlTrack {
60
+ tableName: string
61
+ operation: 'INSERT' | 'UPDATE' | 'DELETE'
62
+ returnRows?: boolean
63
+ rowColumns?: string[]
64
+ }
65
+ interface SqlExecStatement {
66
+ sql: string
67
+ params?: unknown[]
68
+ track?: SqlTrack
69
+ }
70
+ interface SocketAttachment {
71
+ clientID: string
72
+ clientGroupID: string
73
+ userID: string
74
+ cookie: string | null
75
+ initialized: boolean
76
+ desiredTableNames: string[]
77
+ desiredQueries: DesiredQuery[]
78
+ }
79
+ interface HibernatableWebSocket extends WebSocket {
80
+ serializeAttachment(value: SocketAttachment): void
81
+ deserializeAttachment(): SocketAttachment | undefined
82
+ }
83
+
84
+ const SCHEMA_VERSION = 1
85
+ const SQL_ERROR_SNIPPET_RADIUS = 1600
86
+ const SQL_ERROR_FALLBACK_LIMIT = 4000
87
+
88
+ function quoteIdent(name: string): string {
89
+ return `"${name.replace(/"/g, '""')}"`
90
+ }
91
+
92
+ function sqliteErrorOffset(message: string): number | null {
93
+ const marker = 'offset '
94
+ const start = message.indexOf(marker)
95
+ if (start < 0) return null
96
+ let index = start + marker.length
97
+ let digits = ''
98
+ while (index < message.length) {
99
+ const code = message.charCodeAt(index)
100
+ if (code < 48 || code > 57) break
101
+ digits += message[index]
102
+ index++
103
+ }
104
+ if (!digits) return null
105
+ const offset = Number(digits)
106
+ return Number.isFinite(offset) ? offset : null
107
+ }
108
+
109
+ function sqlErrorSnippet(sql: string, message: string): string {
110
+ const offset = sqliteErrorOffset(message)
111
+ if (offset !== null) {
112
+ const start = Math.max(0, offset - SQL_ERROR_SNIPPET_RADIUS)
113
+ const end = Math.min(sql.length, offset + SQL_ERROR_SNIPPET_RADIUS)
114
+ return `${start > 0 ? '...' : ''}${sql.slice(start, end)}${end < sql.length ? '...' : ''}`
115
+ }
116
+ if (sql.length <= SQL_ERROR_FALLBACK_LIMIT) return sql
117
+ return `${sql.slice(0, SQL_ERROR_FALLBACK_LIMIT)}...`
118
+ }
119
+
120
+ export class ZeroDO extends DurableObject {
121
+ private sql: any
122
+ private watermarks: DurableWatermarkState
123
+ private schemaTables = new Set<string>()
124
+ private tableSchemas = new Map<string, SchemaTable>()
125
+
126
+ constructor(ctx: DurableObjectState, env: Env) {
127
+ super(ctx, env)
128
+ this.sql = ctx.storage.sql
129
+ this.watermarks = new DurableWatermarkState(this.sql)
130
+ }
131
+
132
+ async fetch(request: Request): Promise<Response> {
133
+ const url = new URL(request.url)
134
+ if (request.method === 'OPTIONS') {
135
+ return new Response(null, {
136
+ headers: {
137
+ 'Access-Control-Allow-Origin': '*',
138
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
139
+ 'Access-Control-Allow-Headers': '*',
140
+ },
141
+ })
142
+ }
143
+ if (url.pathname.startsWith('/sync/v') && url.pathname.endsWith('/connect'))
144
+ return this.handleSyncConnect(request, url)
145
+ if (
146
+ (url.pathname === '/zero/push' || url.pathname === '/api/zero/push') &&
147
+ request.method === 'POST'
148
+ )
149
+ return this.handleHttpPush(request)
150
+ if (url.pathname === '/exec' && request.method === 'POST')
151
+ return this.handleExec(request)
152
+ if (url.pathname === '/batch' && request.method === 'POST')
153
+ return this.handleBatch(request)
154
+ if (
155
+ url.pathname === '/changes' &&
156
+ (request.method === 'GET' || request.method === 'POST')
157
+ )
158
+ return this.handleChanges(request, url)
159
+ if (url.pathname === '/notify' && request.method === 'POST')
160
+ return Response.json({ ok: true, cookie: this.cookie() })
161
+ return new Response('not found', { status: 404 })
162
+ }
163
+
164
+ // ── Zero sync protocol ──────────────────────────────────────────────────
165
+
166
+ private handleSyncConnect(request: Request, url: URL): Response {
167
+ if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
168
+ return new Response('expected websocket upgrade', { status: 426 })
169
+ }
170
+ const pair = new WebSocketPair()
171
+ const client = pair[0]
172
+ const server = pair[1] as HibernatableWebSocket
173
+
174
+ const clientID = url.searchParams.get('clientID') || 'anon'
175
+ const clientGroupID = url.searchParams.get('clientGroupID') || 'default'
176
+ const userID = url.searchParams.get('userID') || 'anon'
177
+ const wsid = url.searchParams.get('wsid') || crypto.randomUUID()
178
+ const baseCookie = url.searchParams.get('baseCookie')
179
+
180
+ this.ctx.acceptWebSocket(server)
181
+ server.serializeAttachment({
182
+ clientID,
183
+ clientGroupID,
184
+ userID,
185
+ cookie: baseCookie ? baseCookie : null,
186
+ initialized: false,
187
+ desiredTableNames: [],
188
+ desiredQueries: [],
189
+ })
190
+ this.sendJSON(server, ['connected', { wsid, timestamp: Date.now() }])
191
+
192
+ const secProtocol = request.headers.get('sec-websocket-protocol')
193
+ if (secProtocol) {
194
+ const initData = decodeInitConnection(secProtocol)
195
+ if (initData) {
196
+ const clientSchema = initData[1]?.clientSchema as ClientSchema | undefined
197
+ const patch = (initData[1]?.desiredQueriesPatch || []) as DesiredQueryPatchOp[]
198
+ this.applyDesiredQueries(server, patch, clientSchema)
199
+ }
200
+ }
201
+ return new Response(null, {
202
+ status: 101,
203
+ headers: secProtocol ? { 'Sec-WebSocket-Protocol': secProtocol } : undefined,
204
+ webSocket: client,
205
+ } as ResponseInit & { webSocket: WebSocket })
206
+ }
207
+
208
+ async webSocketMessage(socket: WebSocket, messageData: string | ArrayBuffer) {
209
+ this.watermarks.ensureTables()
210
+ const ws = socket as HibernatableWebSocket
211
+ const attachment = this.readSocketAttachment(ws)
212
+ if (!attachment) return
213
+ const message = this.parseMessage(messageData)
214
+ if (!message) return
215
+ const body = message[1] || {}
216
+
217
+ switch (message[0]) {
218
+ case 'initConnection':
219
+ case 'changeDesiredQueries':
220
+ this.applyDesiredQueries(
221
+ ws,
222
+ (body.desiredQueriesPatch || []) as DesiredQueryPatchOp[],
223
+ body.clientSchema as ClientSchema | undefined
224
+ )
225
+ break
226
+ case 'push':
227
+ this.handlePush(ws, attachment, message[1] as PushBody)
228
+ break
229
+ case 'pull':
230
+ this.handlePull(ws, message[1] as any)
231
+ break
232
+ case 'ping':
233
+ this.sendJSON(ws, ['pong', {}])
234
+ break
235
+ }
236
+ }
237
+
238
+ webSocketClose(
239
+ _socket: WebSocket,
240
+ _code: number,
241
+ _reason: string,
242
+ _wasClean: boolean
243
+ ) {}
244
+
245
+ private applyDesiredQueries(
246
+ socket: HibernatableWebSocket,
247
+ patch: DesiredQueryPatchOp[],
248
+ clientSchema?: ClientSchema
249
+ ) {
250
+ const attachment = this.readSocketAttachment(socket)
251
+ if (!attachment) return
252
+ if (clientSchema) this.ensureSchemaTables(clientSchema)
253
+
254
+ let nextAttachment = this.applyDesiredQueryPatch(attachment, patch)
255
+ socket.serializeAttachment(nextAttachment)
256
+
257
+ if (!nextAttachment.initialized) {
258
+ nextAttachment = this.sendSyncPoke(
259
+ socket,
260
+ { ...nextAttachment, initialized: true },
261
+ { lastMutationIDChanges: {}, rowsPatch: [] }
262
+ )
263
+ }
264
+
265
+ if (patch.length === 0) return
266
+
267
+ const rowsPatch = [
268
+ { op: 'clear' as const },
269
+ ...this.rowsPatchForTables(nextAttachment.desiredTableNames),
270
+ ]
271
+ this.sendSyncPoke(socket, nextAttachment, {
272
+ gotQueriesPatch: this.gotQueriesPatch(patch),
273
+ rowsPatch,
274
+ })
275
+ }
276
+
277
+ private applyDesiredQueryPatch(
278
+ attachment: SocketAttachment,
279
+ patch: DesiredQueryPatchOp[]
280
+ ): SocketAttachment {
281
+ const desiredQueries = new Map<string, string[]>()
282
+ for (const query of attachment.desiredQueries || [])
283
+ desiredQueries.set(query.hash, query.tableNames)
284
+
285
+ for (const op of patch) {
286
+ if (op.op === 'clear') {
287
+ desiredQueries.clear()
288
+ } else if (op.op === 'put' && op.hash) {
289
+ desiredQueries.set(op.hash, this.resolveTablesFromPatch([op]))
290
+ } else if (op.op === 'del' && op.hash) {
291
+ desiredQueries.delete(op.hash)
292
+ }
293
+ }
294
+
295
+ const queries = [...desiredQueries.entries()].map(([hash, tableNames]) => ({
296
+ hash,
297
+ tableNames,
298
+ }))
299
+ return {
300
+ ...attachment,
301
+ desiredQueries: queries,
302
+ desiredTableNames: [...new Set(queries.flatMap((query) => query.tableNames))],
303
+ }
304
+ }
305
+
306
+ private gotQueriesPatch(patch: DesiredQueryPatchOp[]) {
307
+ const got: Array<{ op: 'put' | 'del'; hash: string } | { op: 'clear' }> = []
308
+ for (const op of patch) {
309
+ if (op.op === 'clear') got.push({ op: 'clear' })
310
+ else if (op.hash) got.push({ op: op.op, hash: op.hash })
311
+ }
312
+ return got
313
+ }
314
+
315
+ private rowsPatchForTables(tableNames: string[]): any[] {
316
+ const rowsPatch: any[] = []
317
+ for (const tn of tableNames) {
318
+ if (!this.tableExists(tn)) continue
319
+ for (const row of this.readAllRows(tn))
320
+ rowsPatch.push({ op: 'put', tableName: tn, value: row })
321
+ }
322
+ return rowsPatch
323
+ }
324
+
325
+ private resolveTablesFromPatch(patch: DesiredQueryPatchOp[]): string[] {
326
+ const tables: string[] = []
327
+ for (const op of patch) {
328
+ const tableFromName = this.tableNameFromOperationName(op.name)
329
+ if (tableFromName) tables.push(tableFromName)
330
+ if (op.ast) this.extractTableFromAST(op.ast, tables)
331
+ }
332
+ return tables
333
+ }
334
+
335
+ private extractTableFromAST(ast: any, tables: string[]) {
336
+ if (ast?.table) tables.push(ast.table)
337
+ if (ast?.related)
338
+ for (const rel of ast.related) {
339
+ if (rel?.subquery?.table) tables.push(rel.subquery.table)
340
+ if (rel?.subquery?.related) this.extractTableFromAST(rel.subquery, tables)
341
+ }
342
+ }
343
+
344
+ private handlePush(socket: WebSocket, attachment: SocketAttachment, body: PushBody) {
345
+ const mutations = Array.isArray(body?.mutations) ? body.mutations : []
346
+ const before = this.watermark()
347
+ const mutationResults: any[] = []
348
+ const lastMutationIDChanges: Record<string, number> = {}
349
+ for (const m of mutations) {
350
+ const result = this.applyMutation(m)
351
+ mutationResults.push({ id: { clientID: m.clientID, id: m.id }, result })
352
+ lastMutationIDChanges[m.clientID] = m.id
353
+ }
354
+ this.sendJSON(socket, ['pushResponse', { mutations: mutationResults }])
355
+ const after = this.watermark()
356
+ const changes = after > before ? this.readChangesSince(before) : []
357
+ const rowsPatch = changes.map((c) => this.syncRowPatchFromChange(c))
358
+ if (Object.keys(lastMutationIDChanges).length > 0 || rowsPatch.length > 0)
359
+ this.broadcastMutationPoke(attachment, {
360
+ lastMutationIDChanges,
361
+ rowsPatch,
362
+ })
363
+ }
364
+
365
+ private async handleHttpPush(request: Request): Promise<Response> {
366
+ try {
367
+ const body = (await request.json()) as any
368
+ const before = this.watermark()
369
+ const mutations = Array.isArray(body?.mutations) ? body.mutations : []
370
+ const mutationResults: any[] = []
371
+ const lastMutationIDChanges: Record<string, number> = {}
372
+ for (const m of mutations) {
373
+ const result = this.applyMutation(m)
374
+ mutationResults.push({ id: { clientID: m.clientID, id: m.id }, result })
375
+ lastMutationIDChanges[m.clientID] = m.id
376
+ }
377
+ const after = this.watermark()
378
+ const changes = after > before ? this.readChangesSince(before) : []
379
+ const rowsPatch = changes.map((c) => this.syncRowPatchFromChange(c))
380
+ if (Object.keys(lastMutationIDChanges).length > 0 || rowsPatch.length > 0)
381
+ this.broadcastPoke(body?.clientGroupID || 'default', {
382
+ lastMutationIDChanges,
383
+ rowsPatch,
384
+ })
385
+ return Response.json({ mutations: mutationResults })
386
+ } catch (err: any) {
387
+ return Response.json({ error: err.message }, { status: 500 })
388
+ }
389
+ }
390
+
391
+ private handlePull(socket: HibernatableWebSocket, body: { requestID?: string }) {
392
+ this.sendJSON(socket, [
393
+ 'pull',
394
+ {
395
+ requestID: body?.requestID || crypto.randomUUID(),
396
+ cookie: this.cookie(),
397
+ lastMutationIDChanges: {},
398
+ patch: [],
399
+ },
400
+ ])
401
+ }
402
+
403
+ // ── SQL execution endpoints ─────────────────────────────────────────────
404
+
405
+ private async handleExec(request: Request): Promise<Response> {
406
+ let sql = ''
407
+ try {
408
+ const body = (await request.json()) as {
409
+ sql: string
410
+ params?: unknown[]
411
+ track?: SqlTrack
412
+ }
413
+ sql = body.sql
414
+ const params = Array.isArray(body.params) ? body.params : []
415
+ const result = await this.ctx.storage.transaction(() =>
416
+ this.executeSQL(sql, params, body.track)
417
+ )
418
+ return Response.json(result)
419
+ } catch (err: any) {
420
+ const suffix = sql ? ` while executing: ${sqlErrorSnippet(sql, err.message)}` : ''
421
+ console.error(`[exec-500] ${err.message} :: SQL=${sql.slice(0, 800)}`)
422
+ return Response.json({ error: `${err.message}${suffix}` }, { status: 500 })
423
+ }
424
+ }
425
+
426
+ /** Execute multiple statements atomically via ctx.storage.transaction() */
427
+ private async handleBatch(request: Request): Promise<Response> {
428
+ try {
429
+ const { statements } = (await request.json()) as {
430
+ statements: Array<string | SqlExecStatement>
431
+ }
432
+ const allRows = await this.ctx.storage.transaction(() => {
433
+ const results: any[] = []
434
+ for (const statement of statements) {
435
+ const item = typeof statement === 'string' ? { sql: statement } : statement
436
+ if (!item?.sql?.trim()) continue
437
+ try {
438
+ results.push(
439
+ this.executeSQL(
440
+ item.sql,
441
+ Array.isArray(item.params) ? item.params : [],
442
+ item.track
443
+ )
444
+ )
445
+ } catch (err: any) {
446
+ throw new Error(
447
+ `${err.message} while executing: ${sqlErrorSnippet(item.sql, err.message)}`
448
+ )
449
+ }
450
+ }
451
+ return results
452
+ })
453
+ return Response.json({ results: allRows })
454
+ } catch (err: any) {
455
+ return Response.json({ error: err.message }, { status: 500 })
456
+ }
457
+ }
458
+
459
+ private async handleChanges(request: Request, url: URL): Promise<Response> {
460
+ try {
461
+ let watermark = Number(
462
+ url.searchParams.get('watermark') ?? url.searchParams.get('since') ?? 0
463
+ )
464
+ let limit = Number(url.searchParams.get('limit') ?? 1000)
465
+ if (request.method === 'POST') {
466
+ const body = (await request.json().catch(() => ({}))) as {
467
+ watermark?: unknown
468
+ since?: unknown
469
+ limit?: unknown
470
+ }
471
+ watermark = Number(body.watermark ?? body.since ?? watermark)
472
+ limit = Number(body.limit ?? limit)
473
+ }
474
+ if (!Number.isFinite(watermark) || watermark < 0) watermark = 0
475
+ if (!Number.isFinite(limit) || limit <= 0) limit = 1000
476
+ return Response.json({
477
+ watermark: this.watermark(),
478
+ changes: this.readChangesSince(watermark).slice(0, Math.min(limit, 10_000)),
479
+ })
480
+ } catch (err: any) {
481
+ return Response.json({ error: err.message }, { status: 500 })
482
+ }
483
+ }
484
+
485
+ private executeSQL(
486
+ sql: string,
487
+ params: unknown[] = [],
488
+ track?: SqlTrack
489
+ ): { rows: Record<string, unknown>[]; columns: string[]; affectedRows?: number } {
490
+ const cursor = this.sql.exec(sql, ...params)
491
+ const columns = Array.isArray(cursor.columnNames) ? cursor.columnNames : []
492
+ const rows = this.cursorRows(cursor)
493
+ if (!track) return { rows, columns }
494
+
495
+ for (const row of rows) {
496
+ const trackedRow = trackedChangeRow(row, track)
497
+ if (track.operation === 'DELETE')
498
+ this.appendTrackedChange(track.tableName, 'DELETE', null, trackedRow)
499
+ else this.appendTrackedChange(track.tableName, track.operation, trackedRow, null)
500
+ }
501
+
502
+ return {
503
+ rows: track.returnRows ? rows : [],
504
+ columns: track.returnRows ? columns : [],
505
+ affectedRows: rows.length,
506
+ }
507
+ }
508
+
509
+ private cursorRows(cursor: any): Record<string, unknown>[] {
510
+ return cursor.toArray().map((row: any) => {
511
+ const obj: Record<string, unknown> = {}
512
+ for (const k of Object.keys(row)) obj[k] = row[k]
513
+ return obj
514
+ })
515
+ }
516
+
517
+ // ── CRUD operations ──────────────────────────────────────────────────────
518
+
519
+ private applyMutation(mutation: PushMutation) {
520
+ if (mutation.type === 'crud' && mutation.name === '_zero_crud') {
521
+ return this.applyCrudMutation(mutation)
522
+ }
523
+ if (mutation.name === '_zero_cleanupResults') return {}
524
+ if (mutation.type === 'custom') return this.applyTableMutation(mutation)
525
+ return {
526
+ error: 'app',
527
+ message: `unsupported mutation ${mutation.type}:${mutation.name}`,
528
+ }
529
+ }
530
+
531
+ private applyTableMutation(mutation: PushMutation) {
532
+ const [tableName, action] = this.tableActionFromMutationName(mutation.name)
533
+ if (!tableName || !action)
534
+ return { error: 'app', message: `invalid mutation name ${mutation.name}` }
535
+ if (!this.tableExists(tableName))
536
+ return { error: 'app', message: `unknown table ${tableName}` }
537
+ const value = (mutation.args[0] || {}) as Record<string, unknown>
538
+ const primaryKey = this.primaryKeyForTable(tableName, [])
539
+
540
+ if (action === 'insert') this.insertRow(tableName, value, primaryKey)
541
+ else if (action === 'upsert') this.upsertRow(tableName, value, primaryKey)
542
+ else if (action === 'delete') this.deleteRow(tableName, value, primaryKey)
543
+ else this.updateRow(tableName, value, primaryKey)
544
+ return {}
545
+ }
546
+
547
+ private tableActionFromMutationName(name: string): [string, string] {
548
+ if (name.includes('|')) return name.split('|', 2) as [string, string]
549
+ return name.split('.', 2) as [string, string]
550
+ }
551
+
552
+ private tableNameFromOperationName(name?: string): string | null {
553
+ if (!name) return null
554
+ return name.split(/[.|]/, 1)[0] || null
555
+ }
556
+
557
+ private applyCrudMutation(mutation: PushMutation) {
558
+ const arg = mutation.args[0] as { ops?: CrudOp[] } | undefined
559
+ const ops = Array.isArray(arg?.ops) ? arg.ops : []
560
+ for (const crud of ops) {
561
+ if (!crud?.tableName) return { error: 'app', message: 'invalid crud mutation' }
562
+ if (!this.tableExists(crud.tableName))
563
+ return { error: 'app', message: `unknown table ${crud.tableName}` }
564
+ const value = crud.value || {}
565
+ const primaryKey = this.primaryKeyForTable(crud.tableName, crud.primaryKey || [])
566
+ if (crud.op === 'insert') this.insertRow(crud.tableName, value, primaryKey)
567
+ else if (crud.op === 'upsert') this.upsertRow(crud.tableName, value, primaryKey)
568
+ else if (crud.op === 'update') this.updateRow(crud.tableName, value, primaryKey)
569
+ else if (crud.op === 'delete') this.deleteRow(crud.tableName, value, primaryKey)
570
+ }
571
+ return {}
572
+ }
573
+
574
+ private insertRow(tn: string, value: Record<string, unknown>, pk: string[]) {
575
+ if (this.readRowByPrimaryKey(tn, value, pk)) return
576
+ const row = this.storageRow(tn, value, true)
577
+ const cols = Object.keys(row)
578
+ if (!cols.length) return
579
+ const qc = cols.map((c) => quoteIdent(c)).join(', ')
580
+ const ph = cols.map(() => '?').join(', ')
581
+ this.sql.exec(
582
+ `INSERT INTO ${quoteIdent(tn)} (${qc}) VALUES (${ph})`,
583
+ ...cols.map((c) => row[c])
584
+ )
585
+ const next = this.readRowByPrimaryKey(tn, value, pk) || this.normalizeRow(tn, row)
586
+ this.appendChange(tn, 'INSERT', next, null)
587
+ }
588
+
589
+ private upsertRow(tn: string, value: Record<string, unknown>, pk: string[]) {
590
+ const existing = this.readRowByPrimaryKey(tn, value, pk)
591
+ if (existing) {
592
+ this.updateRow(tn, value, pk)
593
+ return
594
+ }
595
+ this.insertRow(tn, value, pk)
596
+ }
597
+
598
+ private updateRow(tn: string, value: Record<string, unknown>, pk: string[]) {
599
+ if (!pk.length) return
600
+ const existing = this.readRowByPrimaryKey(tn, value, pk)
601
+ if (!existing) return
602
+ const nk = Object.keys(value).filter((c) => !pk.includes(c))
603
+ if (!nk.length) return
604
+ const storage = this.storageRow(tn, value, false)
605
+ this.sql.exec(
606
+ `UPDATE ${quoteIdent(tn)} SET ${nk.map((c) => `${quoteIdent(c)} = ?`).join(', ')} WHERE ${this.primaryKeyWhere(pk)}`,
607
+ ...nk.map((c) => storage[c]),
608
+ ...pk.map((c) => this.storageColumnValue(tn, c, value[c]))
609
+ )
610
+ const next = this.readRowByPrimaryKey(tn, value, pk)
611
+ if (next) this.appendChange(tn, 'UPDATE', next, existing)
612
+ }
613
+
614
+ private deleteRow(tn: string, value: Record<string, unknown>, pk: string[]) {
615
+ if (!pk.length) return
616
+ const existing = this.readRowByPrimaryKey(tn, value, pk)
617
+ if (!existing) return
618
+ this.sql.exec(
619
+ `DELETE FROM ${quoteIdent(tn)} WHERE ${this.primaryKeyWhere(pk)}`,
620
+ ...pk.map((c) => this.storageColumnValue(tn, c, value[c]))
621
+ )
622
+ this.appendChange(tn, 'DELETE', null, existing)
623
+ }
624
+
625
+ private appendChange(
626
+ tn: string,
627
+ op: 'INSERT' | 'UPDATE' | 'DELETE',
628
+ rowData: Record<string, unknown> | null,
629
+ oldData: Record<string, unknown> | null
630
+ ) {
631
+ this.appendTrackedChange(tn, op, rowData, oldData)
632
+ }
633
+
634
+ private appendTrackedChange(
635
+ tableName: string,
636
+ op: 'INSERT' | 'UPDATE' | 'DELETE',
637
+ rowData: Record<string, unknown> | null,
638
+ oldData: Record<string, unknown> | null
639
+ ) {
640
+ this.watermarks.ensureTables()
641
+ const watermark = this.watermarks.next()
642
+ this.sql.exec(
643
+ 'INSERT INTO _zero_changes (watermark, table_name, op, row_data, old_data) VALUES (?, ?, ?, ?, ?)',
644
+ watermark,
645
+ tableName,
646
+ op,
647
+ rowData ? JSON.stringify(rowData) : null,
648
+ oldData ? JSON.stringify(oldData) : null
649
+ )
650
+ this.watermarks.mark(watermark)
651
+ }
652
+
653
+ private readChangesSince(watermark: number) {
654
+ this.watermarks.ensureTables()
655
+ return this.sql
656
+ .exec(
657
+ 'SELECT watermark, table_name, op, row_data, old_data FROM _zero_changes WHERE watermark > ? ORDER BY watermark',
658
+ watermark
659
+ )
660
+ .toArray()
661
+ .map((row: any) => ({
662
+ watermark: Number(row.watermark),
663
+ tableName: String(row.table_name),
664
+ op: String(row.op),
665
+ rowData: row.row_data ? JSON.parse(String(row.row_data)) : null,
666
+ oldData: row.old_data ? JSON.parse(String(row.old_data)) : null,
667
+ }))
668
+ }
669
+
670
+ private watermark(): number {
671
+ return this.watermarks.current()
672
+ }
673
+
674
+ private ensureSchemaTables(clientSchema: ClientSchema) {
675
+ this.ensureSchemaMetadataTable()
676
+ for (const [name, def] of Object.entries(clientSchema.tables)) {
677
+ this.tableSchemas.set(name, def)
678
+ this.sql.exec(
679
+ 'INSERT OR REPLACE INTO _zero_schema_tables (name, schema_json) VALUES (?, ?)',
680
+ name,
681
+ JSON.stringify(def)
682
+ )
683
+ if (this.schemaTables.has(name)) continue
684
+ const pk = def.primaryKey.map((c) => quoteIdent(c))
685
+ const pkClause = pk.length ? `, PRIMARY KEY (${pk.join(', ')})` : ''
686
+ const colDefs = Object.entries(def.columns).map(([cn, cd]) => {
687
+ const t: Record<string, string> = {
688
+ string: 'TEXT',
689
+ number: 'REAL',
690
+ boolean: 'INTEGER',
691
+ json: 'TEXT',
692
+ bigint: 'TEXT',
693
+ }
694
+ return `${quoteIdent(cn)} ${t[cd.type] || 'TEXT'}`
695
+ })
696
+ this.sql.exec(
697
+ `CREATE TABLE IF NOT EXISTS ${quoteIdent(name)} (${colDefs.join(', ')}${pkClause})`
698
+ )
699
+ this.schemaTables.add(name)
700
+ }
701
+ }
702
+
703
+ private ensureSchemaMetadataTable() {
704
+ this.sql.exec(
705
+ 'CREATE TABLE IF NOT EXISTS _zero_schema_tables (name TEXT PRIMARY KEY, schema_json TEXT NOT NULL)'
706
+ )
707
+ }
708
+
709
+ private schemaForTable(tableName: string): SchemaTable | undefined {
710
+ const cached = this.tableSchemas.get(tableName)
711
+ if (cached) return cached
712
+ try {
713
+ this.ensureSchemaMetadataTable()
714
+ const row = this.sql
715
+ .exec('SELECT schema_json FROM _zero_schema_tables WHERE name = ?', tableName)
716
+ .one()
717
+ if (!row?.schema_json) return undefined
718
+ const schema = JSON.parse(String(row.schema_json)) as SchemaTable
719
+ this.tableSchemas.set(tableName, schema)
720
+ return schema
721
+ } catch {
722
+ return undefined
723
+ }
724
+ }
725
+
726
+ private tableExists(n: string): boolean {
727
+ try {
728
+ return !!this.sql
729
+ .exec("SELECT name FROM sqlite_master WHERE type='table' AND name=?", n)
730
+ .one()
731
+ } catch {
732
+ return false
733
+ }
734
+ }
735
+
736
+ private readAllRows(tn: string): Record<string, unknown>[] {
737
+ try {
738
+ return this.sql
739
+ .exec(`SELECT * FROM ${quoteIdent(tn)}`)
740
+ .toArray()
741
+ .map((row: any) => this.normalizeRow(tn, row))
742
+ } catch {
743
+ return []
744
+ }
745
+ }
746
+
747
+ private readRowByPrimaryKey(
748
+ tn: string,
749
+ value: Record<string, unknown>,
750
+ pk: string[]
751
+ ): Record<string, unknown> | null {
752
+ if (!pk.length) return null
753
+ try {
754
+ const row = this.sql
755
+ .exec(
756
+ `SELECT * FROM ${quoteIdent(tn)} WHERE ${this.primaryKeyWhere(pk)}`,
757
+ ...pk.map((c) => this.storageColumnValue(tn, c, value[c]))
758
+ )
759
+ .one()
760
+ return row ? this.normalizeRow(tn, row) : null
761
+ } catch {
762
+ return null
763
+ }
764
+ }
765
+
766
+ private primaryKeyWhere(pk: string[]): string {
767
+ return pk.map((c) => `${quoteIdent(c)} = ?`).join(' AND ')
768
+ }
769
+
770
+ private primaryKeyForTable(tn: string, fallback: string[]): string[] {
771
+ const schema = this.schemaForTable(tn)
772
+ if (schema?.primaryKey?.length) return schema.primaryKey
773
+ return fallback
774
+ }
775
+
776
+ private storageRow(
777
+ tn: string,
778
+ value: Record<string, unknown>,
779
+ includeMissingSchemaColumns: boolean
780
+ ): Record<string, unknown> {
781
+ const schema = this.schemaForTable(tn)
782
+ const row: Record<string, unknown> = {}
783
+ if (schema && includeMissingSchemaColumns) {
784
+ for (const column of Object.keys(schema.columns))
785
+ row[column] = this.storageColumnValue(tn, column, value[column] ?? null)
786
+ }
787
+ for (const column of Object.keys(value)) {
788
+ if (value[column] !== undefined)
789
+ row[column] = this.storageColumnValue(tn, column, value[column])
790
+ }
791
+ return row
792
+ }
793
+
794
+ private storageColumnValue(tn: string, column: string, value: unknown): unknown {
795
+ if (value === undefined || value === null) return null
796
+ const type = this.schemaForTable(tn)?.columns?.[column]?.type
797
+ if (type === 'boolean') return value ? 1 : 0
798
+ if (type === 'json') return typeof value === 'string' ? value : JSON.stringify(value)
799
+ if (type === 'number') return Number(value)
800
+ if (type === 'bigint') return String(value)
801
+ return value
802
+ }
803
+
804
+ private normalizeRow(
805
+ tn: string,
806
+ row: Record<string, unknown>
807
+ ): Record<string, unknown> {
808
+ const schema = this.schemaForTable(tn)
809
+ const normalized: Record<string, unknown> = {}
810
+ for (const key of Object.keys(row)) {
811
+ const type = schema?.columns?.[key]?.type
812
+ const value = row[key]
813
+ if (value === null || value === undefined) {
814
+ normalized[key] = null
815
+ } else if (type === 'boolean') {
816
+ normalized[key] =
817
+ value === true || value === 1 || value === '1' || value === 'true'
818
+ } else if (type === 'number') {
819
+ normalized[key] = Number(value)
820
+ } else if (type === 'json' && typeof value === 'string') {
821
+ try {
822
+ normalized[key] = JSON.parse(value)
823
+ } catch {
824
+ normalized[key] = value
825
+ }
826
+ } else {
827
+ normalized[key] = value
828
+ }
829
+ }
830
+ return normalized
831
+ }
832
+
833
+ private sendSyncPoke(
834
+ socket: HibernatableWebSocket,
835
+ attachment: SocketAttachment,
836
+ part: {
837
+ rowsPatch?: any[]
838
+ gotQueriesPatch?: any[]
839
+ lastMutationIDChanges?: Record<string, number>
840
+ }
841
+ ): SocketAttachment {
842
+ const cookie = this.nextCookie()
843
+ const pokeID = crypto.randomUUID()
844
+ this.sendJSON(socket, [
845
+ 'pokeStart',
846
+ {
847
+ pokeID,
848
+ baseCookie: attachment.cookie,
849
+ schemaVersions: {
850
+ minSupportedVersion: SCHEMA_VERSION,
851
+ maxSupportedVersion: SCHEMA_VERSION,
852
+ },
853
+ timestamp: Date.now(),
854
+ },
855
+ ])
856
+ this.sendJSON(socket, ['pokePart', { pokeID, ...part }])
857
+ this.sendJSON(socket, ['pokeEnd', { pokeID, cookie }])
858
+ const nextAttachment = { ...attachment, cookie }
859
+ socket.serializeAttachment(nextAttachment)
860
+ return nextAttachment
861
+ }
862
+
863
+ private broadcastPoke(
864
+ clientGroupID: string,
865
+ part: { rowsPatch?: any[]; lastMutationIDChanges?: Record<string, number> }
866
+ ) {
867
+ for (const socket of this.ctx.getWebSockets()) {
868
+ const ws = socket as HibernatableWebSocket
869
+ const attachment = this.readSocketAttachment(ws)
870
+ if (!attachment) continue
871
+ if (attachment.clientGroupID !== clientGroupID) continue
872
+ this.sendSyncPoke(ws, attachment, part)
873
+ }
874
+ }
875
+
876
+ private broadcastMutationPoke(
877
+ sourceAttachment: SocketAttachment,
878
+ part: { rowsPatch?: any[]; lastMutationIDChanges?: Record<string, number> }
879
+ ) {
880
+ const rowsPatch = part.rowsPatch || []
881
+ const changedTables = new Set(
882
+ rowsPatch
883
+ .map((op) => op?.tableName)
884
+ .filter((tableName): tableName is string => !!tableName)
885
+ )
886
+ const hasLastMutationIDChanges =
887
+ Object.keys(part.lastMutationIDChanges || {}).length > 0
888
+
889
+ for (const socket of this.ctx.getWebSockets()) {
890
+ const ws = socket as HibernatableWebSocket
891
+ const attachment = this.readSocketAttachment(ws)
892
+ if (!attachment) continue
893
+ if (attachment.userID !== sourceAttachment.userID) continue
894
+
895
+ const isSourceClientGroup =
896
+ attachment.clientGroupID === sourceAttachment.clientGroupID
897
+ const wantsChangedRows =
898
+ changedTables.size > 0 &&
899
+ attachment.desiredTableNames.some((tableName) => changedTables.has(tableName))
900
+
901
+ const nextPart: {
902
+ rowsPatch?: any[]
903
+ lastMutationIDChanges?: Record<string, number>
904
+ } = {}
905
+ if (wantsChangedRows) nextPart.rowsPatch = rowsPatch
906
+ if (isSourceClientGroup && hasLastMutationIDChanges)
907
+ nextPart.lastMutationIDChanges = part.lastMutationIDChanges
908
+
909
+ if (!nextPart.rowsPatch && !nextPart.lastMutationIDChanges) continue
910
+ this.sendSyncPoke(ws, attachment, nextPart)
911
+ }
912
+ }
913
+
914
+ private syncRowPatchFromChange(change: any): any {
915
+ if (change.op === 'DELETE')
916
+ return {
917
+ op: 'del',
918
+ tableName: change.tableName,
919
+ id: this.primaryKeyValue(change.tableName, change.oldData || {}),
920
+ }
921
+ return {
922
+ op: 'put',
923
+ tableName: change.tableName,
924
+ value: this.normalizeRow(change.tableName, change.rowData || {}),
925
+ }
926
+ }
927
+
928
+ private primaryKeyValue(
929
+ tableName: string,
930
+ row: Record<string, unknown>
931
+ ): Record<string, unknown> {
932
+ const pk = this.primaryKeyForTable(tableName, [])
933
+ if (pk.length) return Object.fromEntries(pk.map((column) => [column, row[column]]))
934
+ if ('id' in row) return { id: row.id }
935
+ return row
936
+ }
937
+
938
+ private cookie(): string {
939
+ return String(this.watermark()).padStart(20, '0')
940
+ }
941
+
942
+ private nextCookie(): string {
943
+ const watermark = this.watermarks.next()
944
+ this.watermarks.mark(watermark)
945
+ return String(watermark).padStart(20, '0')
946
+ }
947
+
948
+ private readSocketAttachment(socket: HibernatableWebSocket): SocketAttachment | null {
949
+ const attachment = socket.deserializeAttachment()
950
+ if (!attachment) return null
951
+ return {
952
+ ...attachment,
953
+ initialized: attachment.initialized === true,
954
+ desiredTableNames: attachment.desiredTableNames || [],
955
+ desiredQueries: attachment.desiredQueries || [],
956
+ }
957
+ }
958
+
959
+ private sendJSON(socket: WebSocket, msg: unknown) {
960
+ try {
961
+ socket.send(JSON.stringify(msg))
962
+ } catch {}
963
+ }
964
+ private parseMessage(data: string | ArrayBuffer): unknown {
965
+ try {
966
+ return JSON.parse(typeof data === 'string' ? data : new TextDecoder().decode(data))
967
+ } catch {
968
+ return null
969
+ }
970
+ }
971
+ }
972
+
973
+ export default {
974
+ async fetch(request: Request, env: Env): Promise<Response> {
975
+ const url = new URL(request.url)
976
+ const id = env.ZERO_DO.idFromName('singleton')
977
+ if (url.pathname.startsWith('/sync/v') && url.pathname.endsWith('/connect')) {
978
+ return env.ZERO_DO.get(id).fetch(request)
979
+ }
980
+ if (
981
+ (url.pathname === '/zero/push' || url.pathname === '/api/zero/push') &&
982
+ request.method === 'POST'
983
+ ) {
984
+ return env.ZERO_DO.get(id).fetch(request)
985
+ }
986
+ if (url.pathname === '/exec' && request.method === 'POST') {
987
+ return env.ZERO_DO.get(id).fetch(request)
988
+ }
989
+ if (url.pathname === '/batch' && request.method === 'POST') {
990
+ return env.ZERO_DO.get(id).fetch(request)
991
+ }
992
+ if (
993
+ url.pathname === '/changes' &&
994
+ (request.method === 'GET' || request.method === 'POST')
995
+ ) {
996
+ return env.ZERO_DO.get(id).fetch(request)
997
+ }
998
+ if (url.pathname === '/notify' && request.method === 'POST') {
999
+ return env.ZERO_DO.get(id).fetch(request)
1000
+ }
1001
+ return new Response('not found', { status: 404 })
1002
+ },
1003
+ }
1004
+
1005
+ function decodeInitConnection(
1006
+ secProtocol: string
1007
+ ): [string, Record<string, unknown>] | null {
1008
+ try {
1009
+ const decoded = decodeURIComponent(secProtocol)
1010
+ const bytes = Uint8Array.from(atob(decoded), (char) => char.charCodeAt(0))
1011
+ const protocols = JSON.parse(new TextDecoder().decode(bytes)) as {
1012
+ initConnectionMessage?: unknown
1013
+ }
1014
+ const message = protocols.initConnectionMessage
1015
+ if (Array.isArray(message) && message[0] === 'initConnection') {
1016
+ return message as [string, Record<string, unknown>]
1017
+ }
1018
+ return null
1019
+ } catch {
1020
+ return null
1021
+ }
1022
+ }
1023
+
1024
+ interface DurableObjectState {
1025
+ storage: { sql: any; transaction<T>(fn: () => T | Promise<T>): Promise<T> }
1026
+ acceptWebSocket(socket: WebSocket, tags?: string[]): void
1027
+ getWebSockets(tag?: string): WebSocket[]
1028
+ }
1029
+ interface WebSocketPair {
1030
+ 0: WebSocket
1031
+ 1: WebSocket
1032
+ }
1033
+ declare const WebSocketPair: { new (): { 0: WebSocket; 1: WebSocket } }