orez 0.2.25 → 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 (117) hide show
  1. package/dist/cf-do/watermark.d.ts +21 -0
  2. package/dist/cf-do/watermark.d.ts.map +1 -0
  3. package/dist/cf-do/watermark.js +93 -0
  4. package/dist/cf-do/watermark.js.map +1 -0
  5. package/dist/cf-do/worker.d.ts +48 -22
  6. package/dist/cf-do/worker.d.ts.map +1 -1
  7. package/dist/cf-do/worker.js +642 -269
  8. package/dist/cf-do/worker.js.map +1 -1
  9. package/dist/config.js +1 -1
  10. package/dist/config.js.map +1 -1
  11. package/dist/do-sql-tracking.d.ts +6 -0
  12. package/dist/do-sql-tracking.d.ts.map +1 -0
  13. package/dist/do-sql-tracking.js +14 -0
  14. package/dist/do-sql-tracking.js.map +1 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +28 -14
  17. package/dist/index.js.map +1 -1
  18. package/dist/pg-proxy-browser.js +6 -6
  19. package/dist/pg-proxy-browser.js.map +1 -1
  20. package/dist/pg-proxy-do-backend.d.ts +96 -17
  21. package/dist/pg-proxy-do-backend.d.ts.map +1 -1
  22. package/dist/pg-proxy-do-backend.js +6033 -454
  23. package/dist/pg-proxy-do-backend.js.map +1 -1
  24. package/dist/replication/change-tracker.d.ts.map +1 -1
  25. package/dist/replication/change-tracker.js +18 -1
  26. package/dist/replication/change-tracker.js.map +1 -1
  27. package/dist/replication/handler.d.ts.map +1 -1
  28. package/dist/replication/handler.js +7 -2
  29. package/dist/replication/handler.js.map +1 -1
  30. package/dist/replication/pgoutput-encoder.d.ts.map +1 -1
  31. package/dist/replication/pgoutput-encoder.js +72 -30
  32. package/dist/replication/pgoutput-encoder.js.map +1 -1
  33. package/dist/worker/browser-build-config.d.ts.map +1 -1
  34. package/dist/worker/browser-build-config.js +2 -1
  35. package/dist/worker/browser-build-config.js.map +1 -1
  36. package/dist/worker/cf-patches.d.ts +5 -2
  37. package/dist/worker/cf-patches.d.ts.map +1 -1
  38. package/dist/worker/cf-patches.js +238 -4
  39. package/dist/worker/cf-patches.js.map +1 -1
  40. package/dist/worker/shims/node-stub.d.ts +35 -0
  41. package/dist/worker/shims/node-stub.d.ts.map +1 -1
  42. package/dist/worker/shims/node-stub.js +53 -1
  43. package/dist/worker/shims/node-stub.js.map +1 -1
  44. package/dist/worker/shims/oxfmt.d.ts +4 -0
  45. package/dist/worker/shims/oxfmt.d.ts.map +1 -0
  46. package/dist/worker/shims/oxfmt.js +4 -0
  47. package/dist/worker/shims/oxfmt.js.map +1 -0
  48. package/dist/worker/shims/postgres-socket.js +1 -1
  49. package/dist/worker/shims/postgres-socket.js.map +1 -1
  50. package/dist/worker/shims/sqlite.d.ts +1 -0
  51. package/dist/worker/shims/sqlite.d.ts.map +1 -1
  52. package/dist/worker/shims/sqlite.js +229 -9
  53. package/dist/worker/shims/sqlite.js.map +1 -1
  54. package/dist/worker/shims/ws.d.ts.map +1 -1
  55. package/dist/worker/shims/ws.js +45 -0
  56. package/dist/worker/shims/ws.js.map +1 -1
  57. package/dist/worker/shims/zero-process-env.d.ts +2 -0
  58. package/dist/worker/shims/zero-process-env.d.ts.map +1 -0
  59. package/dist/worker/shims/zero-process-env.js +9 -0
  60. package/dist/worker/shims/zero-process-env.js.map +1 -0
  61. package/dist/worker/zero-cache-embed-cf.d.ts +29 -12
  62. package/dist/worker/zero-cache-embed-cf.d.ts.map +1 -1
  63. package/dist/worker/zero-cache-embed-cf.js +83 -14
  64. package/dist/worker/zero-cache-embed-cf.js.map +1 -1
  65. package/package.json +6 -2
  66. package/src/cf-do/.wrangler/cache/cf.json +1 -0
  67. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
  68. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
  69. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
  70. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/0f0f3bdf0abda097eb6f1246db4657d9fc622081362d894d82c1a1ce067b05b6.sqlite +0 -0
  71. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/1ddd3a4a48a11b51658444f5458a1fb175194b1d5b6a5bda20ef3fe3205b900c.sqlite +0 -0
  72. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/204a39120310d37e972c5914cfd71ad55c151bdb9e8ed289a5f8c5b052dd60e4.sqlite +0 -0
  73. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/3835f242df9728adba3d127a238793fd054ed3e51df3f60749ee744c469bf2a2.sqlite +0 -0
  74. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/4aa9c80eb716cf55b8995ccf7afab0b36c683e6da07d7c37a3f9c570136036df.sqlite +0 -0
  75. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/533e2fd1d6ea46e7a9a0017916ef341802d438d72583462755f2c1f8225e9bf2.sqlite +0 -0
  76. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/5ffa1aced1225ecaeac6366f7586aa3de92761cdff8711d81fbd81f248076abd.sqlite +0 -0
  77. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/686c3a9f0d7e59ed2ab607efd4b76d779c97cafeb3818380033bf7c7eb86c819.sqlite +0 -0
  78. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/6e8214e8dcfadd0deb52d64e5e9ca85c6b329ace11193909845995396914c473.sqlite +0 -0
  79. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/78d9ec9ff873d3fe3507ff53c2a6f6dfc408b4268eb0db3f2a146c0678965366.sqlite +0 -0
  80. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/7eff9f0ed7e27ad0d3f9d923de0682fab1928591172c1ba336c5f79a134a5d85.sqlite +0 -0
  81. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/836cda5b995b25867d722ed4f4c2292167e80351a3c6038db626648eb247dd8b.sqlite +0 -0
  82. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/91ef63b112209ab30172763acd8a0935106c248f7f1bcae5545ce37a9f201551.sqlite +0 -0
  83. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/a66ea4293a5f5938bc6d116edfa2522bb85bc37aea3541fbc09c3b613b9b32c0.sqlite +0 -0
  84. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/ceb2ab26b80590840b65651deb6e948d3bf81565c6751f3a58752cf4bf4aecae.sqlite +0 -0
  85. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite +0 -0
  86. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-shm +0 -0
  87. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-wal +0 -0
  88. package/src/cf-do/ARCHITECTURE.md +83 -0
  89. package/src/cf-do/watermark.test.ts +103 -0
  90. package/src/cf-do/watermark.ts +118 -0
  91. package/src/cf-do/worker.ts +1033 -0
  92. package/src/cf-do/wrangler.toml +11 -0
  93. package/src/config.ts +1 -1
  94. package/src/do-sql-tracking.test.ts +19 -0
  95. package/src/do-sql-tracking.ts +19 -0
  96. package/src/index.ts +29 -14
  97. package/src/pg-proxy-browser.ts +6 -6
  98. package/src/pg-proxy-do-backend.test.ts +3890 -0
  99. package/src/pg-proxy-do-backend.ts +6799 -482
  100. package/src/replication/change-tracker.ts +16 -1
  101. package/src/replication/handler.test.ts +35 -0
  102. package/src/replication/handler.ts +7 -2
  103. package/src/replication/pgoutput-encoder.test.ts +71 -2
  104. package/src/replication/pgoutput-encoder.ts +65 -30
  105. package/src/worker/browser-build-config.test.ts +12 -0
  106. package/src/worker/browser-build-config.ts +2 -1
  107. package/src/worker/cf-patches.ts +274 -4
  108. package/src/worker/shims/node-stub.ts +53 -1
  109. package/src/worker/shims/oxfmt.ts +3 -0
  110. package/src/worker/shims/postgres-socket.ts +1 -1
  111. package/src/worker/shims/sqlite.test.ts +145 -0
  112. package/src/worker/shims/sqlite.ts +256 -9
  113. package/src/worker/shims/ws.ts +45 -0
  114. package/src/worker/shims/zero-process-env.ts +11 -0
  115. package/src/worker/zero-cache-embed-cf.ts +114 -18
  116. package/src/query-rewrites.test.ts +0 -30
  117. package/src/query-rewrites.ts +0 -152
@@ -1,69 +1,112 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ZeroDO = void 0;
4
1
  // @ts-nocheck — cloudflare:workers types not available in orez
5
- const cloudflare_workers_1 = require("cloudflare:workers");
6
- // ── constants ─────────────────────────────────────────────────────────
2
+ import { DurableObject } from 'cloudflare:workers';
3
+ import { trackedChangeRow } from '../do-sql-tracking.js';
4
+ import { DurableWatermarkState } from './watermark.js';
7
5
  const SCHEMA_VERSION = 1;
8
- // ── Durable Object ────────────────────────────────────────────────────
9
- class ZeroDO extends cloudflare_workers_1.DurableObject {
6
+ const SQL_ERROR_SNIPPET_RADIUS = 1600;
7
+ const SQL_ERROR_FALLBACK_LIMIT = 4000;
8
+ function quoteIdent(name) {
9
+ return `"${name.replace(/"/g, '""')}"`;
10
+ }
11
+ function sqliteErrorOffset(message) {
12
+ const marker = 'offset ';
13
+ const start = message.indexOf(marker);
14
+ if (start < 0)
15
+ return null;
16
+ let index = start + marker.length;
17
+ let digits = '';
18
+ while (index < message.length) {
19
+ const code = message.charCodeAt(index);
20
+ if (code < 48 || code > 57)
21
+ break;
22
+ digits += message[index];
23
+ index++;
24
+ }
25
+ if (!digits)
26
+ return null;
27
+ const offset = Number(digits);
28
+ return Number.isFinite(offset) ? offset : null;
29
+ }
30
+ function sqlErrorSnippet(sql, message) {
31
+ const offset = sqliteErrorOffset(message);
32
+ if (offset !== null) {
33
+ const start = Math.max(0, offset - SQL_ERROR_SNIPPET_RADIUS);
34
+ const end = Math.min(sql.length, offset + SQL_ERROR_SNIPPET_RADIUS);
35
+ return `${start > 0 ? '...' : ''}${sql.slice(start, end)}${end < sql.length ? '...' : ''}`;
36
+ }
37
+ if (sql.length <= SQL_ERROR_FALLBACK_LIMIT)
38
+ return sql;
39
+ return `${sql.slice(0, SQL_ERROR_FALLBACK_LIMIT)}...`;
40
+ }
41
+ export class ZeroDO extends DurableObject {
10
42
  sql;
11
- initialized = false;
12
- // track which tables have been created from clientSchema
43
+ watermarks;
13
44
  schemaTables = new Set();
45
+ tableSchemas = new Map();
14
46
  constructor(ctx, env) {
15
47
  super(ctx, env);
16
48
  this.sql = ctx.storage.sql;
49
+ this.watermarks = new DurableWatermarkState(this.sql);
17
50
  }
18
- // ── fetch handler (HTTP + WebSocket upgrade) ────────────────────────
19
51
  async fetch(request) {
20
- this.ensureInternalTables();
21
52
  const url = new URL(request.url);
22
53
  if (request.method === 'OPTIONS') {
23
54
  return new Response(null, {
24
- headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': '*' },
55
+ headers: {
56
+ 'Access-Control-Allow-Origin': '*',
57
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
58
+ 'Access-Control-Allow-Headers': '*',
59
+ },
25
60
  });
26
61
  }
27
- // Zero sync protocol WebSocket endpoint
28
- if (url.pathname === '/sync/v49/connect') {
62
+ if (url.pathname.startsWith('/sync/v') && url.pathname.endsWith('/connect'))
29
63
  return this.handleSyncConnect(request, url);
30
- }
31
- // Push mutations via HTTP (used by app server to proxy pushes)
32
- if (url.pathname === '/zero/push' && request.method === 'POST') {
64
+ if ((url.pathname === '/zero/push' || url.pathname === '/api/zero/push') &&
65
+ request.method === 'POST')
33
66
  return this.handleHttpPush(request);
34
- }
67
+ if (url.pathname === '/exec' && request.method === 'POST')
68
+ return this.handleExec(request);
69
+ if (url.pathname === '/batch' && request.method === 'POST')
70
+ return this.handleBatch(request);
71
+ if (url.pathname === '/changes' &&
72
+ (request.method === 'GET' || request.method === 'POST'))
73
+ return this.handleChanges(request, url);
74
+ if (url.pathname === '/notify' && request.method === 'POST')
75
+ return Response.json({ ok: true, cookie: this.cookie() });
35
76
  return new Response('not found', { status: 404 });
36
77
  }
37
- // ── Zero sync protocol WebSocket handler ────────────────────────────
78
+ // ── Zero sync protocol ──────────────────────────────────────────────────
38
79
  handleSyncConnect(request, url) {
39
80
  if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
40
81
  return new Response('expected websocket upgrade', { status: 426 });
41
82
  }
42
83
  const pair = new WebSocketPair();
43
84
  const client = pair[0];
44
- const socket = pair[1];
85
+ const server = pair[1];
45
86
  const clientID = url.searchParams.get('clientID') || 'anon';
46
87
  const clientGroupID = url.searchParams.get('clientGroupID') || 'default';
47
88
  const userID = url.searchParams.get('userID') || 'anon';
48
- const baseCookieParam = url.searchParams.get('baseCookie');
49
- this.ctx.acceptWebSocket(socket);
50
- const attachment = {
89
+ const wsid = url.searchParams.get('wsid') || crypto.randomUUID();
90
+ const baseCookie = url.searchParams.get('baseCookie');
91
+ this.ctx.acceptWebSocket(server);
92
+ server.serializeAttachment({
51
93
  clientID,
52
94
  clientGroupID,
53
95
  userID,
54
- cookie: baseCookieParam || null,
96
+ cookie: baseCookie ? baseCookie : null,
97
+ initialized: false,
55
98
  desiredTableNames: [],
56
- };
57
- socket.serializeAttachment(attachment);
58
- // Send connected message
59
- this.sendJSON(socket, ['connected', { wsid: url.searchParams.get('wsid') || crypto.randomUUID(), timestamp: Date.now() }]);
60
- // Decode init connection from Sec-WebSocket-Protocol header
99
+ desiredQueries: [],
100
+ });
101
+ this.sendJSON(server, ['connected', { wsid, timestamp: Date.now() }]);
61
102
  const secProtocol = request.headers.get('sec-websocket-protocol');
62
- const initData = secProtocol ? this.decodeInitConnection(secProtocol) : null;
63
- if (initData) {
64
- const clientSchema = initData[1]?.clientSchema;
65
- const patch = (initData[1]?.desiredQueriesPatch || []);
66
- this.applyDesiredQueries(socket, patch, clientSchema);
103
+ if (secProtocol) {
104
+ const initData = decodeInitConnection(secProtocol);
105
+ if (initData) {
106
+ const clientSchema = initData[1]?.clientSchema;
107
+ const patch = (initData[1]?.desiredQueriesPatch || []);
108
+ this.applyDesiredQueries(server, patch, clientSchema);
109
+ }
67
110
  }
68
111
  return new Response(null, {
69
112
  status: 101,
@@ -71,209 +114,392 @@ class ZeroDO extends cloudflare_workers_1.DurableObject {
71
114
  webSocket: client,
72
115
  });
73
116
  }
74
- // ── WebSocket message handler ───────────────────────────────────────
75
117
  async webSocketMessage(socket, messageData) {
76
- this.ensureInternalTables();
77
- const socketState = socket;
78
- const attachment = socketState.deserializeAttachment();
118
+ this.watermarks.ensureTables();
119
+ const ws = socket;
120
+ const attachment = this.readSocketAttachment(ws);
79
121
  if (!attachment)
80
122
  return;
81
123
  const message = this.parseMessage(messageData);
82
124
  if (!message)
83
125
  return;
126
+ const body = message[1] || {};
84
127
  switch (message[0]) {
85
128
  case 'initConnection':
86
- case 'changeDesiredQueries': {
87
- const patch = (message[1]?.desiredQueriesPatch || []);
88
- const clientSchema = message[1]?.clientSchema;
89
- this.applyDesiredQueries(socketState, patch, clientSchema);
129
+ case 'changeDesiredQueries':
130
+ this.applyDesiredQueries(ws, (body.desiredQueriesPatch || []), body.clientSchema);
90
131
  break;
91
- }
92
132
  case 'push':
93
- this.handlePush(socketState, attachment, message[1]);
133
+ this.handlePush(ws, attachment, message[1]);
94
134
  break;
95
- case 'ping':
96
- this.sendJSON(socketState, ['pong', {}]);
135
+ case 'pull':
136
+ this.handlePull(ws, message[1]);
97
137
  break;
98
- case 'deleteClients':
99
- case 'closeConnection':
100
- case 'ackMutationResponses':
101
- case 'updateAuth':
102
- // no-op for experiment
138
+ case 'ping':
139
+ this.sendJSON(ws, ['pong', {}]);
103
140
  break;
104
141
  }
105
142
  }
106
- async webSocketClose(socket, code, reason, wasClean) {
107
- // cleanup if needed
108
- }
109
- // ── handle desired queries (initial data / query change) ────────────
143
+ webSocketClose(_socket, _code, _reason, _wasClean) { }
110
144
  applyDesiredQueries(socket, patch, clientSchema) {
111
- const attachment = socket.deserializeAttachment();
145
+ const attachment = this.readSocketAttachment(socket);
112
146
  if (!attachment)
113
147
  return;
114
- // Create tables from client schema
115
- if (clientSchema) {
148
+ if (clientSchema)
116
149
  this.ensureSchemaTables(clientSchema);
117
- }
118
- // Track desired tables from AST references
119
- const tableNames = this.resolveTablesFromPatch(patch);
120
- const allTableNames = [...new Set([...attachment.desiredTableNames, ...tableNames])];
121
- const nextAttachment = {
122
- ...attachment,
123
- desiredTableNames: allTableNames,
124
- };
150
+ let nextAttachment = this.applyDesiredQueryPatch(attachment, patch);
125
151
  socket.serializeAttachment(nextAttachment);
126
- // Send initial poke with all rows for desired tables
127
- if (allTableNames.length > 0) {
128
- const rowsPatch = [];
129
- for (const tableName of allTableNames) {
130
- if (!this.tableExists(tableName))
131
- continue;
132
- const rows = this.readAllRows(tableName);
133
- for (const row of rows) {
134
- rowsPatch.push({ op: 'put', tableName, value: row });
135
- }
152
+ if (!nextAttachment.initialized) {
153
+ nextAttachment = this.sendSyncPoke(socket, { ...nextAttachment, initialized: true }, { lastMutationIDChanges: {}, rowsPatch: [] });
154
+ }
155
+ if (patch.length === 0)
156
+ return;
157
+ const rowsPatch = [
158
+ { op: 'clear' },
159
+ ...this.rowsPatchForTables(nextAttachment.desiredTableNames),
160
+ ];
161
+ this.sendSyncPoke(socket, nextAttachment, {
162
+ gotQueriesPatch: this.gotQueriesPatch(patch),
163
+ rowsPatch,
164
+ });
165
+ }
166
+ applyDesiredQueryPatch(attachment, patch) {
167
+ const desiredQueries = new Map();
168
+ for (const query of attachment.desiredQueries || [])
169
+ desiredQueries.set(query.hash, query.tableNames);
170
+ for (const op of patch) {
171
+ if (op.op === 'clear') {
172
+ desiredQueries.clear();
173
+ }
174
+ else if (op.op === 'put' && op.hash) {
175
+ desiredQueries.set(op.hash, this.resolveTablesFromPatch([op]));
136
176
  }
137
- if (rowsPatch.length > 0) {
138
- this.sendSyncPoke(socket, attachment, { rowsPatch });
177
+ else if (op.op === 'del' && op.hash) {
178
+ desiredQueries.delete(op.hash);
139
179
  }
140
180
  }
181
+ const queries = [...desiredQueries.entries()].map(([hash, tableNames]) => ({
182
+ hash,
183
+ tableNames,
184
+ }));
185
+ return {
186
+ ...attachment,
187
+ desiredQueries: queries,
188
+ desiredTableNames: [...new Set(queries.flatMap((query) => query.tableNames))],
189
+ };
141
190
  }
142
- // ── resolve table names from query AST ──────────────────────────────
143
- resolveTablesFromPatch(patch) {
144
- const tableNames = [];
191
+ gotQueriesPatch(patch) {
192
+ const got = [];
145
193
  for (const op of patch) {
146
- if (!op.ast)
194
+ if (op.op === 'clear')
195
+ got.push({ op: 'clear' });
196
+ else if (op.hash)
197
+ got.push({ op: op.op, hash: op.hash });
198
+ }
199
+ return got;
200
+ }
201
+ rowsPatchForTables(tableNames) {
202
+ const rowsPatch = [];
203
+ for (const tn of tableNames) {
204
+ if (!this.tableExists(tn))
147
205
  continue;
148
- // Extract table references from the AST
149
- const tables = this.extractTableFromAST(op.ast);
150
- tableNames.push(...tables);
206
+ for (const row of this.readAllRows(tn))
207
+ rowsPatch.push({ op: 'put', tableName: tn, value: row });
151
208
  }
152
- return tableNames;
209
+ return rowsPatch;
153
210
  }
154
- extractTableFromAST(ast) {
211
+ resolveTablesFromPatch(patch) {
155
212
  const tables = [];
213
+ for (const op of patch) {
214
+ const tableFromName = this.tableNameFromOperationName(op.name);
215
+ if (tableFromName)
216
+ tables.push(tableFromName);
217
+ if (op.ast)
218
+ this.extractTableFromAST(op.ast, tables);
219
+ }
220
+ return tables;
221
+ }
222
+ extractTableFromAST(ast, tables) {
156
223
  if (ast?.table)
157
224
  tables.push(ast.table);
158
- if (ast?.related) {
225
+ if (ast?.related)
159
226
  for (const rel of ast.related) {
160
227
  if (rel?.subquery?.table)
161
228
  tables.push(rel.subquery.table);
162
- // recurse into nested related
163
- if (rel?.subquery?.related) {
164
- tables.push(...this.extractTableFromAST(rel.subquery));
165
- }
229
+ if (rel?.subquery?.related)
230
+ this.extractTableFromAST(rel.subquery, tables);
166
231
  }
167
- }
168
- return tables;
169
232
  }
170
- // ── handle push mutations ──────────────────────────────────────────
171
233
  handlePush(socket, attachment, body) {
172
234
  const mutations = Array.isArray(body?.mutations) ? body.mutations : [];
173
- const beforeWatermark = this.currentWatermark();
235
+ const before = this.watermark();
174
236
  const mutationResults = [];
175
237
  const lastMutationIDChanges = {};
176
- for (const mutation of mutations) {
177
- if (mutation.type === 'crud' && mutation.name === '_zero_crud') {
178
- this.applyCrudMutation(mutation);
179
- }
180
- mutationResults.push({
181
- id: { clientID: mutation.clientID, id: mutation.id },
182
- result: {},
183
- });
184
- lastMutationIDChanges[mutation.clientID] = mutation.id;
238
+ for (const m of mutations) {
239
+ const result = this.applyMutation(m);
240
+ mutationResults.push({ id: { clientID: m.clientID, id: m.id }, result });
241
+ lastMutationIDChanges[m.clientID] = m.id;
185
242
  }
186
- // Send push response
187
243
  this.sendJSON(socket, ['pushResponse', { mutations: mutationResults }]);
188
- // Build poke from changes
189
- const afterWatermark = this.currentWatermark();
190
- if (afterWatermark > beforeWatermark) {
191
- const changes = this.readChangesSince(beforeWatermark);
192
- const rowsPatch = changes.map((change) => this.syncRowPatchFromChange(change));
193
- if (rowsPatch.length > 0) {
194
- this.broadcastPoke(attachment.clientGroupID, { lastMutationIDChanges, rowsPatch });
195
- }
196
- }
244
+ const after = this.watermark();
245
+ const changes = after > before ? this.readChangesSince(before) : [];
246
+ const rowsPatch = changes.map((c) => this.syncRowPatchFromChange(c));
247
+ if (Object.keys(lastMutationIDChanges).length > 0 || rowsPatch.length > 0)
248
+ this.broadcastMutationPoke(attachment, {
249
+ lastMutationIDChanges,
250
+ rowsPatch,
251
+ });
197
252
  }
198
- // ── HTTP push (used by app server proxy) ────────────────────────────
199
253
  async handleHttpPush(request) {
200
254
  try {
201
- const body = await request.json();
255
+ const body = (await request.json());
256
+ const before = this.watermark();
202
257
  const mutations = Array.isArray(body?.mutations) ? body.mutations : [];
203
- const beforeWatermark = this.currentWatermark();
204
- for (const mutation of mutations) {
205
- if (mutation.type === 'crud' && mutation.name === '_zero_crud') {
206
- this.applyCrudMutation(mutation);
207
- }
258
+ const mutationResults = [];
259
+ const lastMutationIDChanges = {};
260
+ for (const m of mutations) {
261
+ const result = this.applyMutation(m);
262
+ mutationResults.push({ id: { clientID: m.clientID, id: m.id }, result });
263
+ lastMutationIDChanges[m.clientID] = m.id;
208
264
  }
209
- const afterWatermark = this.currentWatermark();
210
- if (afterWatermark > beforeWatermark) {
211
- const changes = this.readChangesSince(beforeWatermark);
212
- const rowsPatch = changes.map((change) => this.syncRowPatchFromChange(change));
213
- if (rowsPatch.length > 0) {
214
- this.broadcastPoke('default', { rowsPatch });
265
+ const after = this.watermark();
266
+ const changes = after > before ? this.readChangesSince(before) : [];
267
+ const rowsPatch = changes.map((c) => this.syncRowPatchFromChange(c));
268
+ if (Object.keys(lastMutationIDChanges).length > 0 || rowsPatch.length > 0)
269
+ this.broadcastPoke(body?.clientGroupID || 'default', {
270
+ lastMutationIDChanges,
271
+ rowsPatch,
272
+ });
273
+ return Response.json({ mutations: mutationResults });
274
+ }
275
+ catch (err) {
276
+ return Response.json({ error: err.message }, { status: 500 });
277
+ }
278
+ }
279
+ handlePull(socket, body) {
280
+ this.sendJSON(socket, [
281
+ 'pull',
282
+ {
283
+ requestID: body?.requestID || crypto.randomUUID(),
284
+ cookie: this.cookie(),
285
+ lastMutationIDChanges: {},
286
+ patch: [],
287
+ },
288
+ ]);
289
+ }
290
+ // ── SQL execution endpoints ─────────────────────────────────────────────
291
+ async handleExec(request) {
292
+ let sql = '';
293
+ try {
294
+ const body = (await request.json());
295
+ sql = body.sql;
296
+ const params = Array.isArray(body.params) ? body.params : [];
297
+ const result = await this.ctx.storage.transaction(() => this.executeSQL(sql, params, body.track));
298
+ return Response.json(result);
299
+ }
300
+ catch (err) {
301
+ const suffix = sql ? ` while executing: ${sqlErrorSnippet(sql, err.message)}` : '';
302
+ console.error(`[exec-500] ${err.message} :: SQL=${sql.slice(0, 800)}`);
303
+ return Response.json({ error: `${err.message}${suffix}` }, { status: 500 });
304
+ }
305
+ }
306
+ /** Execute multiple statements atomically via ctx.storage.transaction() */
307
+ async handleBatch(request) {
308
+ try {
309
+ const { statements } = (await request.json());
310
+ const allRows = await this.ctx.storage.transaction(() => {
311
+ const results = [];
312
+ for (const statement of statements) {
313
+ const item = typeof statement === 'string' ? { sql: statement } : statement;
314
+ if (!item?.sql?.trim())
315
+ continue;
316
+ try {
317
+ results.push(this.executeSQL(item.sql, Array.isArray(item.params) ? item.params : [], item.track));
318
+ }
319
+ catch (err) {
320
+ throw new Error(`${err.message} while executing: ${sqlErrorSnippet(item.sql, err.message)}`);
321
+ }
215
322
  }
323
+ return results;
324
+ });
325
+ return Response.json({ results: allRows });
326
+ }
327
+ catch (err) {
328
+ return Response.json({ error: err.message }, { status: 500 });
329
+ }
330
+ }
331
+ async handleChanges(request, url) {
332
+ try {
333
+ let watermark = Number(url.searchParams.get('watermark') ?? url.searchParams.get('since') ?? 0);
334
+ let limit = Number(url.searchParams.get('limit') ?? 1000);
335
+ if (request.method === 'POST') {
336
+ const body = (await request.json().catch(() => ({})));
337
+ watermark = Number(body.watermark ?? body.since ?? watermark);
338
+ limit = Number(body.limit ?? limit);
216
339
  }
217
- return Response.json({ ok: true });
340
+ if (!Number.isFinite(watermark) || watermark < 0)
341
+ watermark = 0;
342
+ if (!Number.isFinite(limit) || limit <= 0)
343
+ limit = 1000;
344
+ return Response.json({
345
+ watermark: this.watermark(),
346
+ changes: this.readChangesSince(watermark).slice(0, Math.min(limit, 10_000)),
347
+ });
218
348
  }
219
349
  catch (err) {
220
350
  return Response.json({ error: err.message }, { status: 500 });
221
351
  }
222
352
  }
223
- // ── apply a single CRUD mutation ────────────────────────────────────
353
+ executeSQL(sql, params = [], track) {
354
+ const cursor = this.sql.exec(sql, ...params);
355
+ const columns = Array.isArray(cursor.columnNames) ? cursor.columnNames : [];
356
+ const rows = this.cursorRows(cursor);
357
+ if (!track)
358
+ return { rows, columns };
359
+ for (const row of rows) {
360
+ const trackedRow = trackedChangeRow(row, track);
361
+ if (track.operation === 'DELETE')
362
+ this.appendTrackedChange(track.tableName, 'DELETE', null, trackedRow);
363
+ else
364
+ this.appendTrackedChange(track.tableName, track.operation, trackedRow, null);
365
+ }
366
+ return {
367
+ rows: track.returnRows ? rows : [],
368
+ columns: track.returnRows ? columns : [],
369
+ affectedRows: rows.length,
370
+ };
371
+ }
372
+ cursorRows(cursor) {
373
+ return cursor.toArray().map((row) => {
374
+ const obj = {};
375
+ for (const k of Object.keys(row))
376
+ obj[k] = row[k];
377
+ return obj;
378
+ });
379
+ }
380
+ // ── CRUD operations ──────────────────────────────────────────────────────
381
+ applyMutation(mutation) {
382
+ if (mutation.type === 'crud' && mutation.name === '_zero_crud') {
383
+ return this.applyCrudMutation(mutation);
384
+ }
385
+ if (mutation.name === '_zero_cleanupResults')
386
+ return {};
387
+ if (mutation.type === 'custom')
388
+ return this.applyTableMutation(mutation);
389
+ return {
390
+ error: 'app',
391
+ message: `unsupported mutation ${mutation.type}:${mutation.name}`,
392
+ };
393
+ }
394
+ applyTableMutation(mutation) {
395
+ const [tableName, action] = this.tableActionFromMutationName(mutation.name);
396
+ if (!tableName || !action)
397
+ return { error: 'app', message: `invalid mutation name ${mutation.name}` };
398
+ if (!this.tableExists(tableName))
399
+ return { error: 'app', message: `unknown table ${tableName}` };
400
+ const value = (mutation.args[0] || {});
401
+ const primaryKey = this.primaryKeyForTable(tableName, []);
402
+ if (action === 'insert')
403
+ this.insertRow(tableName, value, primaryKey);
404
+ else if (action === 'upsert')
405
+ this.upsertRow(tableName, value, primaryKey);
406
+ else if (action === 'delete')
407
+ this.deleteRow(tableName, value, primaryKey);
408
+ else
409
+ this.updateRow(tableName, value, primaryKey);
410
+ return {};
411
+ }
412
+ tableActionFromMutationName(name) {
413
+ if (name.includes('|'))
414
+ return name.split('|', 2);
415
+ return name.split('.', 2);
416
+ }
417
+ tableNameFromOperationName(name) {
418
+ if (!name)
419
+ return null;
420
+ return name.split(/[.|]/, 1)[0] || null;
421
+ }
224
422
  applyCrudMutation(mutation) {
225
423
  const arg = mutation.args[0];
226
424
  const ops = Array.isArray(arg?.ops) ? arg.ops : [];
227
425
  for (const crud of ops) {
228
- if (!crud || typeof crud !== 'object')
229
- continue;
230
- const tableName = crud.tableName;
231
- if (!tableName || !this.tableExists(tableName))
232
- continue;
426
+ if (!crud?.tableName)
427
+ return { error: 'app', message: 'invalid crud mutation' };
428
+ if (!this.tableExists(crud.tableName))
429
+ return { error: 'app', message: `unknown table ${crud.tableName}` };
233
430
  const value = crud.value || {};
234
- if (crud.op === 'insert' || crud.op === 'upsert') {
235
- this.upsertRow(tableName, value);
236
- }
237
- else if (crud.op === 'update') {
238
- this.updateRow(tableName, value, crud.primaryKey || []);
239
- }
240
- else if (crud.op === 'delete') {
241
- this.deleteRow(tableName, value, crud.primaryKey || []);
242
- }
431
+ const primaryKey = this.primaryKeyForTable(crud.tableName, crud.primaryKey || []);
432
+ if (crud.op === 'insert')
433
+ this.insertRow(crud.tableName, value, primaryKey);
434
+ else if (crud.op === 'upsert')
435
+ this.upsertRow(crud.tableName, value, primaryKey);
436
+ else if (crud.op === 'update')
437
+ this.updateRow(crud.tableName, value, primaryKey);
438
+ else if (crud.op === 'delete')
439
+ this.deleteRow(crud.tableName, value, primaryKey);
440
+ }
441
+ return {};
442
+ }
443
+ insertRow(tn, value, pk) {
444
+ if (this.readRowByPrimaryKey(tn, value, pk))
445
+ return;
446
+ const row = this.storageRow(tn, value, true);
447
+ const cols = Object.keys(row);
448
+ if (!cols.length)
449
+ return;
450
+ const qc = cols.map((c) => quoteIdent(c)).join(', ');
451
+ const ph = cols.map(() => '?').join(', ');
452
+ this.sql.exec(`INSERT INTO ${quoteIdent(tn)} (${qc}) VALUES (${ph})`, ...cols.map((c) => row[c]));
453
+ const next = this.readRowByPrimaryKey(tn, value, pk) || this.normalizeRow(tn, row);
454
+ this.appendChange(tn, 'INSERT', next, null);
455
+ }
456
+ upsertRow(tn, value, pk) {
457
+ const existing = this.readRowByPrimaryKey(tn, value, pk);
458
+ if (existing) {
459
+ this.updateRow(tn, value, pk);
460
+ return;
243
461
  }
462
+ this.insertRow(tn, value, pk);
244
463
  }
245
- // ── row operations ──────────────────────────────────────────────────
246
- upsertRow(tableName, value) {
247
- const columns = Object.keys(value);
248
- if (columns.length === 0)
464
+ updateRow(tn, value, pk) {
465
+ if (!pk.length)
249
466
  return;
250
- const quotedCols = columns.map((c) => `"${c}"`).join(', ');
251
- const placeholders = columns.map(() => '?').join(', ');
252
- const updateSet = columns.map((c) => `"${c}" = ?`).join(', ');
253
- this.sql.exec(`INSERT INTO "${tableName}" (${quotedCols}) VALUES (${placeholders}) ON CONFLICT DO UPDATE SET ${updateSet}`, ...columns.map((c) => value[c]), ...columns.map((c) => value[c]));
254
- this.appendChange(tableName, 'INSERT', value, null);
255
- }
256
- updateRow(tableName, value, primaryKey) {
257
- const nonKeyCols = Object.keys(value).filter((c) => !primaryKey.includes(c));
258
- if (nonKeyCols.length === 0)
467
+ const existing = this.readRowByPrimaryKey(tn, value, pk);
468
+ if (!existing)
259
469
  return;
260
- const updateSet = nonKeyCols.map((c) => `"${c}" = ?`).join(', ');
261
- const whereClause = primaryKey.map((c) => `"${c}" = ?`).join(' AND ');
262
- this.sql.exec(`UPDATE "${tableName}" SET ${updateSet} WHERE ${whereClause}`, ...nonKeyCols.map((c) => value[c]), ...primaryKey.map((c) => value[c]));
263
- this.appendChange(tableName, 'UPDATE', value, null);
470
+ const nk = Object.keys(value).filter((c) => !pk.includes(c));
471
+ if (!nk.length)
472
+ return;
473
+ const storage = this.storageRow(tn, value, false);
474
+ this.sql.exec(`UPDATE ${quoteIdent(tn)} SET ${nk.map((c) => `${quoteIdent(c)} = ?`).join(', ')} WHERE ${this.primaryKeyWhere(pk)}`, ...nk.map((c) => storage[c]), ...pk.map((c) => this.storageColumnValue(tn, c, value[c])));
475
+ const next = this.readRowByPrimaryKey(tn, value, pk);
476
+ if (next)
477
+ this.appendChange(tn, 'UPDATE', next, existing);
264
478
  }
265
- deleteRow(tableName, value, primaryKey) {
266
- const whereClause = primaryKey.map((c) => `"${c}" = ?`).join(' AND ');
267
- this.sql.exec(`DELETE FROM "${tableName}" WHERE ${whereClause}`, ...primaryKey.map((c) => value[c]));
268
- this.appendChange(tableName, 'DELETE', null, value);
479
+ deleteRow(tn, value, pk) {
480
+ if (!pk.length)
481
+ return;
482
+ const existing = this.readRowByPrimaryKey(tn, value, pk);
483
+ if (!existing)
484
+ return;
485
+ this.sql.exec(`DELETE FROM ${quoteIdent(tn)} WHERE ${this.primaryKeyWhere(pk)}`, ...pk.map((c) => this.storageColumnValue(tn, c, value[c])));
486
+ this.appendChange(tn, 'DELETE', null, existing);
269
487
  }
270
- // ── change tracking ─────────────────────────────────────────────────
271
- appendChange(tableName, op, rowData, oldData) {
272
- this.sql.exec('INSERT INTO _zero_changes (table_name, op, row_data, old_data) VALUES (?, ?, ?, ?)', tableName, op, rowData ? JSON.stringify(rowData) : null, oldData ? JSON.stringify(oldData) : null);
488
+ appendChange(tn, op, rowData, oldData) {
489
+ this.appendTrackedChange(tn, op, rowData, oldData);
490
+ }
491
+ appendTrackedChange(tableName, op, rowData, oldData) {
492
+ this.watermarks.ensureTables();
493
+ const watermark = this.watermarks.next();
494
+ this.sql.exec('INSERT INTO _zero_changes (watermark, table_name, op, row_data, old_data) VALUES (?, ?, ?, ?, ?)', watermark, tableName, op, rowData ? JSON.stringify(rowData) : null, oldData ? JSON.stringify(oldData) : null);
495
+ this.watermarks.mark(watermark);
273
496
  }
274
497
  readChangesSince(watermark) {
275
- const cursor = this.sql.exec('SELECT watermark, table_name, op, row_data, old_data FROM _zero_changes WHERE watermark > ? ORDER BY watermark', watermark);
276
- return cursor.toArray().map((row) => ({
498
+ this.watermarks.ensureTables();
499
+ return this.sql
500
+ .exec('SELECT watermark, table_name, op, row_data, old_data FROM _zero_changes WHERE watermark > ? ORDER BY watermark', watermark)
501
+ .toArray()
502
+ .map((row) => ({
277
503
  watermark: Number(row.watermark),
278
504
  tableName: String(row.table_name),
279
505
  op: String(row.op),
@@ -281,160 +507,307 @@ class ZeroDO extends cloudflare_workers_1.DurableObject {
281
507
  oldData: row.old_data ? JSON.parse(String(row.old_data)) : null,
282
508
  }));
283
509
  }
284
- currentWatermark() {
285
- const row = this.sql.exec('SELECT COALESCE(MAX(watermark), 0) AS watermark FROM _zero_changes').one();
286
- return row?.watermark ?? 0;
287
- }
288
- // ── table management ────────────────────────────────────────────────
289
- ensureInternalTables() {
290
- if (this.initialized)
291
- return;
292
- this.initialized = true;
293
- this.sql.exec(`
294
- CREATE TABLE IF NOT EXISTS _zero_changes (
295
- watermark INTEGER PRIMARY KEY AUTOINCREMENT,
296
- table_name TEXT NOT NULL,
297
- op TEXT NOT NULL CHECK (op IN ('INSERT', 'UPDATE', 'DELETE')),
298
- row_data TEXT,
299
- old_data TEXT,
300
- created_at INTEGER NOT NULL DEFAULT (unixepoch())
301
- )
302
- `);
510
+ watermark() {
511
+ return this.watermarks.current();
303
512
  }
304
513
  ensureSchemaTables(clientSchema) {
514
+ this.ensureSchemaMetadataTable();
305
515
  for (const [name, def] of Object.entries(clientSchema.tables)) {
516
+ this.tableSchemas.set(name, def);
517
+ this.sql.exec('INSERT OR REPLACE INTO _zero_schema_tables (name, schema_json) VALUES (?, ?)', name, JSON.stringify(def));
306
518
  if (this.schemaTables.has(name))
307
519
  continue;
308
- this.createTable(name, def);
520
+ const pk = def.primaryKey.map((c) => quoteIdent(c));
521
+ const pkClause = pk.length ? `, PRIMARY KEY (${pk.join(', ')})` : '';
522
+ const colDefs = Object.entries(def.columns).map(([cn, cd]) => {
523
+ const t = {
524
+ string: 'TEXT',
525
+ number: 'REAL',
526
+ boolean: 'INTEGER',
527
+ json: 'TEXT',
528
+ bigint: 'TEXT',
529
+ };
530
+ return `${quoteIdent(cn)} ${t[cd.type] || 'TEXT'}`;
531
+ });
532
+ this.sql.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(name)} (${colDefs.join(', ')}${pkClause})`);
309
533
  this.schemaTables.add(name);
310
534
  }
311
535
  }
312
- createTable(name, def) {
313
- const pkColumns = def.primaryKey.map((c) => `"${c}"`);
314
- const pkClause = pkColumns.length > 0 ? `, PRIMARY KEY (${pkColumns.join(', ')})` : '';
315
- const columnDefs = Object.entries(def.columns).map(([colName, colDef]) => {
316
- const sqlType = this.mapZeroTypeToSQLite(colDef.type);
317
- return `"${colName}" ${sqlType}`;
318
- });
319
- this.sql.exec(`CREATE TABLE IF NOT EXISTS "${name}" (${columnDefs.join(', ')}${pkClause})`);
536
+ ensureSchemaMetadataTable() {
537
+ this.sql.exec('CREATE TABLE IF NOT EXISTS _zero_schema_tables (name TEXT PRIMARY KEY, schema_json TEXT NOT NULL)');
320
538
  }
321
- mapZeroTypeToSQLite(zeroType) {
322
- switch (zeroType) {
323
- case 'string': return 'TEXT';
324
- case 'number': return 'REAL';
325
- case 'boolean': return 'INTEGER';
326
- case 'json': return 'TEXT';
327
- case 'bigint': return 'TEXT';
328
- default: return 'TEXT';
539
+ schemaForTable(tableName) {
540
+ const cached = this.tableSchemas.get(tableName);
541
+ if (cached)
542
+ return cached;
543
+ try {
544
+ this.ensureSchemaMetadataTable();
545
+ const row = this.sql
546
+ .exec('SELECT schema_json FROM _zero_schema_tables WHERE name = ?', tableName)
547
+ .one();
548
+ if (!row?.schema_json)
549
+ return undefined;
550
+ const schema = JSON.parse(String(row.schema_json));
551
+ this.tableSchemas.set(tableName, schema);
552
+ return schema;
553
+ }
554
+ catch {
555
+ return undefined;
329
556
  }
330
557
  }
331
- tableExists(name) {
558
+ tableExists(n) {
332
559
  try {
333
- const row = this.sql.exec(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, name).one();
334
- return !!row;
560
+ return !!this.sql
561
+ .exec("SELECT name FROM sqlite_master WHERE type='table' AND name=?", n)
562
+ .one();
335
563
  }
336
564
  catch {
337
565
  return false;
338
566
  }
339
567
  }
340
- readAllRows(tableName) {
568
+ readAllRows(tn) {
341
569
  try {
342
- const cursor = this.sql.exec(`SELECT * FROM "${tableName}"`);
343
- return cursor.toArray().map((row) => {
344
- const result = {};
345
- // SQLite DO returns columns as properties
346
- for (const key of Object.keys(row)) {
347
- result[key] = row[key];
348
- }
349
- return result;
350
- });
570
+ return this.sql
571
+ .exec(`SELECT * FROM ${quoteIdent(tn)}`)
572
+ .toArray()
573
+ .map((row) => this.normalizeRow(tn, row));
351
574
  }
352
575
  catch {
353
576
  return [];
354
577
  }
355
578
  }
356
- // ── poke helpers ────────────────────────────────────────────────────
579
+ readRowByPrimaryKey(tn, value, pk) {
580
+ if (!pk.length)
581
+ return null;
582
+ try {
583
+ const row = this.sql
584
+ .exec(`SELECT * FROM ${quoteIdent(tn)} WHERE ${this.primaryKeyWhere(pk)}`, ...pk.map((c) => this.storageColumnValue(tn, c, value[c])))
585
+ .one();
586
+ return row ? this.normalizeRow(tn, row) : null;
587
+ }
588
+ catch {
589
+ return null;
590
+ }
591
+ }
592
+ primaryKeyWhere(pk) {
593
+ return pk.map((c) => `${quoteIdent(c)} = ?`).join(' AND ');
594
+ }
595
+ primaryKeyForTable(tn, fallback) {
596
+ const schema = this.schemaForTable(tn);
597
+ if (schema?.primaryKey?.length)
598
+ return schema.primaryKey;
599
+ return fallback;
600
+ }
601
+ storageRow(tn, value, includeMissingSchemaColumns) {
602
+ const schema = this.schemaForTable(tn);
603
+ const row = {};
604
+ if (schema && includeMissingSchemaColumns) {
605
+ for (const column of Object.keys(schema.columns))
606
+ row[column] = this.storageColumnValue(tn, column, value[column] ?? null);
607
+ }
608
+ for (const column of Object.keys(value)) {
609
+ if (value[column] !== undefined)
610
+ row[column] = this.storageColumnValue(tn, column, value[column]);
611
+ }
612
+ return row;
613
+ }
614
+ storageColumnValue(tn, column, value) {
615
+ if (value === undefined || value === null)
616
+ return null;
617
+ const type = this.schemaForTable(tn)?.columns?.[column]?.type;
618
+ if (type === 'boolean')
619
+ return value ? 1 : 0;
620
+ if (type === 'json')
621
+ return typeof value === 'string' ? value : JSON.stringify(value);
622
+ if (type === 'number')
623
+ return Number(value);
624
+ if (type === 'bigint')
625
+ return String(value);
626
+ return value;
627
+ }
628
+ normalizeRow(tn, row) {
629
+ const schema = this.schemaForTable(tn);
630
+ const normalized = {};
631
+ for (const key of Object.keys(row)) {
632
+ const type = schema?.columns?.[key]?.type;
633
+ const value = row[key];
634
+ if (value === null || value === undefined) {
635
+ normalized[key] = null;
636
+ }
637
+ else if (type === 'boolean') {
638
+ normalized[key] =
639
+ value === true || value === 1 || value === '1' || value === 'true';
640
+ }
641
+ else if (type === 'number') {
642
+ normalized[key] = Number(value);
643
+ }
644
+ else if (type === 'json' && typeof value === 'string') {
645
+ try {
646
+ normalized[key] = JSON.parse(value);
647
+ }
648
+ catch {
649
+ normalized[key] = value;
650
+ }
651
+ }
652
+ else {
653
+ normalized[key] = value;
654
+ }
655
+ }
656
+ return normalized;
657
+ }
357
658
  sendSyncPoke(socket, attachment, part) {
358
- const watermark = this.currentWatermark();
359
- const cookie = String(watermark).padStart(20, '0');
659
+ const cookie = this.nextCookie();
360
660
  const pokeID = crypto.randomUUID();
361
- this.sendJSON(socket, ['pokeStart', {
661
+ this.sendJSON(socket, [
662
+ 'pokeStart',
663
+ {
362
664
  pokeID,
363
665
  baseCookie: attachment.cookie,
364
- schemaVersions: { minSupportedVersion: SCHEMA_VERSION, maxSupportedVersion: SCHEMA_VERSION },
666
+ schemaVersions: {
667
+ minSupportedVersion: SCHEMA_VERSION,
668
+ maxSupportedVersion: SCHEMA_VERSION,
669
+ },
365
670
  timestamp: Date.now(),
366
- }]);
671
+ },
672
+ ]);
367
673
  this.sendJSON(socket, ['pokePart', { pokeID, ...part }]);
368
674
  this.sendJSON(socket, ['pokeEnd', { pokeID, cookie }]);
369
- socket.serializeAttachment({ ...attachment, cookie });
675
+ const nextAttachment = { ...attachment, cookie };
676
+ socket.serializeAttachment(nextAttachment);
677
+ return nextAttachment;
370
678
  }
371
679
  broadcastPoke(clientGroupID, part) {
372
- const websockets = this.ctx.getWebSockets();
373
- for (const ws of websockets) {
374
- const socket = ws;
375
- const attachment = socket.deserializeAttachment();
680
+ for (const socket of this.ctx.getWebSockets()) {
681
+ const ws = socket;
682
+ const attachment = this.readSocketAttachment(ws);
376
683
  if (!attachment)
377
684
  continue;
378
- this.sendSyncPoke(socket, attachment, part);
685
+ if (attachment.clientGroupID !== clientGroupID)
686
+ continue;
687
+ this.sendSyncPoke(ws, attachment, part);
379
688
  }
380
689
  }
381
- syncRowPatchFromChange(change) {
382
- if (change.op === 'DELETE') {
383
- const id = change.oldData ? this.extractID(change.oldData) : {};
384
- return { op: 'del', tableName: change.tableName, id };
690
+ broadcastMutationPoke(sourceAttachment, part) {
691
+ const rowsPatch = part.rowsPatch || [];
692
+ const changedTables = new Set(rowsPatch
693
+ .map((op) => op?.tableName)
694
+ .filter((tableName) => !!tableName));
695
+ const hasLastMutationIDChanges = Object.keys(part.lastMutationIDChanges || {}).length > 0;
696
+ for (const socket of this.ctx.getWebSockets()) {
697
+ const ws = socket;
698
+ const attachment = this.readSocketAttachment(ws);
699
+ if (!attachment)
700
+ continue;
701
+ if (attachment.userID !== sourceAttachment.userID)
702
+ continue;
703
+ const isSourceClientGroup = attachment.clientGroupID === sourceAttachment.clientGroupID;
704
+ const wantsChangedRows = changedTables.size > 0 &&
705
+ attachment.desiredTableNames.some((tableName) => changedTables.has(tableName));
706
+ const nextPart = {};
707
+ if (wantsChangedRows)
708
+ nextPart.rowsPatch = rowsPatch;
709
+ if (isSourceClientGroup && hasLastMutationIDChanges)
710
+ nextPart.lastMutationIDChanges = part.lastMutationIDChanges;
711
+ if (!nextPart.rowsPatch && !nextPart.lastMutationIDChanges)
712
+ continue;
713
+ this.sendSyncPoke(ws, attachment, nextPart);
385
714
  }
386
- return { op: 'put', tableName: change.tableName, value: change.rowData };
387
715
  }
388
- extractID(row) {
389
- // For a simple id column, use it directly
390
- if (row.id !== undefined)
716
+ syncRowPatchFromChange(change) {
717
+ if (change.op === 'DELETE')
718
+ return {
719
+ op: 'del',
720
+ tableName: change.tableName,
721
+ id: this.primaryKeyValue(change.tableName, change.oldData || {}),
722
+ };
723
+ return {
724
+ op: 'put',
725
+ tableName: change.tableName,
726
+ value: this.normalizeRow(change.tableName, change.rowData || {}),
727
+ };
728
+ }
729
+ primaryKeyValue(tableName, row) {
730
+ const pk = this.primaryKeyForTable(tableName, []);
731
+ if (pk.length)
732
+ return Object.fromEntries(pk.map((column) => [column, row[column]]));
733
+ if ('id' in row)
391
734
  return { id: row.id };
392
- // Otherwise use first key
393
- const key = Object.keys(row)[0];
394
- return key ? { [key]: row[key] } : {};
735
+ return row;
395
736
  }
396
- // ── message helpers ─────────────────────────────────────────────────
397
- sendJSON(socket, message) {
398
- socket.send(JSON.stringify(message));
737
+ cookie() {
738
+ return String(this.watermark()).padStart(20, '0');
399
739
  }
400
- parseMessage(data) {
401
- try {
402
- const text = typeof data === 'string' ? data : new TextDecoder().decode(data);
403
- return JSON.parse(text);
404
- }
405
- catch {
740
+ nextCookie() {
741
+ const watermark = this.watermarks.next();
742
+ this.watermarks.mark(watermark);
743
+ return String(watermark).padStart(20, '0');
744
+ }
745
+ readSocketAttachment(socket) {
746
+ const attachment = socket.deserializeAttachment();
747
+ if (!attachment)
406
748
  return null;
749
+ return {
750
+ ...attachment,
751
+ initialized: attachment.initialized === true,
752
+ desiredTableNames: attachment.desiredTableNames || [],
753
+ desiredQueries: attachment.desiredQueries || [],
754
+ };
755
+ }
756
+ sendJSON(socket, msg) {
757
+ try {
758
+ socket.send(JSON.stringify(msg));
407
759
  }
760
+ catch { }
408
761
  }
409
- decodeInitConnection(secProtocol) {
762
+ parseMessage(data) {
410
763
  try {
411
- const decoded = atob(secProtocol);
412
- return JSON.parse(decoded);
764
+ return JSON.parse(typeof data === 'string' ? data : new TextDecoder().decode(data));
413
765
  }
414
766
  catch {
415
767
  return null;
416
768
  }
417
769
  }
418
770
  }
419
- exports.ZeroDO = ZeroDO;
420
- // ── Worker entry ───────────────────────────────────────────────────────
421
- exports.default = {
771
+ export default {
422
772
  async fetch(request, env) {
423
773
  const url = new URL(request.url);
424
- // Route to DO: /sync/v49/connect → named DO instance
425
- if (url.pathname === '/sync/v49/connect') {
426
- const clientGroupID = url.searchParams.get('clientGroupID') || 'default';
427
- const doId = env.ZERO_DO.idFromName(clientGroupID);
428
- const stub = env.ZERO_DO.get(doId);
429
- return stub.fetch(request);
430
- }
431
- // Push mutations via HTTP
432
- if (url.pathname === '/zero/push' && request.method === 'POST') {
433
- const doId = env.ZERO_DO.idFromName('default');
434
- const stub = env.ZERO_DO.get(doId);
435
- return stub.fetch(request);
774
+ const id = env.ZERO_DO.idFromName('singleton');
775
+ if (url.pathname.startsWith('/sync/v') && url.pathname.endsWith('/connect')) {
776
+ return env.ZERO_DO.get(id).fetch(request);
777
+ }
778
+ if ((url.pathname === '/zero/push' || url.pathname === '/api/zero/push') &&
779
+ request.method === 'POST') {
780
+ return env.ZERO_DO.get(id).fetch(request);
781
+ }
782
+ if (url.pathname === '/exec' && request.method === 'POST') {
783
+ return env.ZERO_DO.get(id).fetch(request);
784
+ }
785
+ if (url.pathname === '/batch' && request.method === 'POST') {
786
+ return env.ZERO_DO.get(id).fetch(request);
787
+ }
788
+ if (url.pathname === '/changes' &&
789
+ (request.method === 'GET' || request.method === 'POST')) {
790
+ return env.ZERO_DO.get(id).fetch(request);
791
+ }
792
+ if (url.pathname === '/notify' && request.method === 'POST') {
793
+ return env.ZERO_DO.get(id).fetch(request);
436
794
  }
437
795
  return new Response('not found', { status: 404 });
438
796
  },
439
797
  };
798
+ function decodeInitConnection(secProtocol) {
799
+ try {
800
+ const decoded = decodeURIComponent(secProtocol);
801
+ const bytes = Uint8Array.from(atob(decoded), (char) => char.charCodeAt(0));
802
+ const protocols = JSON.parse(new TextDecoder().decode(bytes));
803
+ const message = protocols.initConnectionMessage;
804
+ if (Array.isArray(message) && message[0] === 'initConnection') {
805
+ return message;
806
+ }
807
+ return null;
808
+ }
809
+ catch {
810
+ return null;
811
+ }
812
+ }
440
813
  //# sourceMappingURL=worker.js.map