orez 0.2.25 → 0.2.27

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 (175) 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 +650 -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 +98 -17
  21. package/dist/pg-proxy-do-backend.d.ts.map +1 -1
  22. package/dist/pg-proxy-do-backend.js +6075 -454
  23. package/dist/pg-proxy-do-backend.js.map +1 -1
  24. package/dist/pg-sqlite-compiler/catalog/seed.d.ts +67 -0
  25. package/dist/pg-sqlite-compiler/catalog/seed.d.ts.map +1 -0
  26. package/dist/pg-sqlite-compiler/catalog/seed.js +436 -0
  27. package/dist/pg-sqlite-compiler/catalog/seed.js.map +1 -0
  28. package/dist/pg-sqlite-compiler/index.d.ts +12 -0
  29. package/dist/pg-sqlite-compiler/index.d.ts.map +1 -0
  30. package/dist/pg-sqlite-compiler/index.js +59 -0
  31. package/dist/pg-sqlite-compiler/index.js.map +1 -0
  32. package/dist/pg-sqlite-compiler/passes/ast-utils.d.ts +48 -0
  33. package/dist/pg-sqlite-compiler/passes/ast-utils.d.ts.map +1 -0
  34. package/dist/pg-sqlite-compiler/passes/ast-utils.js +93 -0
  35. package/dist/pg-sqlite-compiler/passes/ast-utils.js.map +1 -0
  36. package/dist/pg-sqlite-compiler/passes/catalog.d.ts +34 -0
  37. package/dist/pg-sqlite-compiler/passes/catalog.d.ts.map +1 -0
  38. package/dist/pg-sqlite-compiler/passes/catalog.js +30 -0
  39. package/dist/pg-sqlite-compiler/passes/catalog.js.map +1 -0
  40. package/dist/pg-sqlite-compiler/passes/datetime.d.ts +21 -0
  41. package/dist/pg-sqlite-compiler/passes/datetime.d.ts.map +1 -0
  42. package/dist/pg-sqlite-compiler/passes/datetime.js +53 -0
  43. package/dist/pg-sqlite-compiler/passes/datetime.js.map +1 -0
  44. package/dist/pg-sqlite-compiler/passes/index.d.ts +21 -0
  45. package/dist/pg-sqlite-compiler/passes/index.d.ts.map +1 -0
  46. package/dist/pg-sqlite-compiler/passes/index.js +39 -0
  47. package/dist/pg-sqlite-compiler/passes/index.js.map +1 -0
  48. package/dist/pg-sqlite-compiler/passes/types.d.ts +41 -0
  49. package/dist/pg-sqlite-compiler/passes/types.d.ts.map +1 -0
  50. package/dist/pg-sqlite-compiler/passes/types.js +103 -0
  51. package/dist/pg-sqlite-compiler/passes/types.js.map +1 -0
  52. package/dist/pg-sqlite-compiler/test/oracle.d.ts +34 -0
  53. package/dist/pg-sqlite-compiler/test/oracle.d.ts.map +1 -0
  54. package/dist/pg-sqlite-compiler/test/oracle.js +204 -0
  55. package/dist/pg-sqlite-compiler/test/oracle.js.map +1 -0
  56. package/dist/pg-sqlite-compiler/types.d.ts +55 -0
  57. package/dist/pg-sqlite-compiler/types.d.ts.map +1 -0
  58. package/dist/pg-sqlite-compiler/types.js +2 -0
  59. package/dist/pg-sqlite-compiler/types.js.map +1 -0
  60. package/dist/replication/change-tracker.d.ts.map +1 -1
  61. package/dist/replication/change-tracker.js +18 -1
  62. package/dist/replication/change-tracker.js.map +1 -1
  63. package/dist/replication/handler.d.ts.map +1 -1
  64. package/dist/replication/handler.js +7 -2
  65. package/dist/replication/handler.js.map +1 -1
  66. package/dist/replication/pgoutput-encoder.d.ts.map +1 -1
  67. package/dist/replication/pgoutput-encoder.js +72 -30
  68. package/dist/replication/pgoutput-encoder.js.map +1 -1
  69. package/dist/worker/browser-build-config.d.ts.map +1 -1
  70. package/dist/worker/browser-build-config.js +2 -1
  71. package/dist/worker/browser-build-config.js.map +1 -1
  72. package/dist/worker/cf-patches.d.ts +5 -2
  73. package/dist/worker/cf-patches.d.ts.map +1 -1
  74. package/dist/worker/cf-patches.js +238 -4
  75. package/dist/worker/cf-patches.js.map +1 -1
  76. package/dist/worker/shims/node-stub.d.ts +35 -0
  77. package/dist/worker/shims/node-stub.d.ts.map +1 -1
  78. package/dist/worker/shims/node-stub.js +53 -1
  79. package/dist/worker/shims/node-stub.js.map +1 -1
  80. package/dist/worker/shims/oxfmt.d.ts +4 -0
  81. package/dist/worker/shims/oxfmt.d.ts.map +1 -0
  82. package/dist/worker/shims/oxfmt.js +4 -0
  83. package/dist/worker/shims/oxfmt.js.map +1 -0
  84. package/dist/worker/shims/postgres-socket.js +1 -1
  85. package/dist/worker/shims/postgres-socket.js.map +1 -1
  86. package/dist/worker/shims/sqlite.d.ts +1 -0
  87. package/dist/worker/shims/sqlite.d.ts.map +1 -1
  88. package/dist/worker/shims/sqlite.js +229 -9
  89. package/dist/worker/shims/sqlite.js.map +1 -1
  90. package/dist/worker/shims/ws.d.ts.map +1 -1
  91. package/dist/worker/shims/ws.js +45 -0
  92. package/dist/worker/shims/ws.js.map +1 -1
  93. package/dist/worker/shims/zero-process-env.d.ts +2 -0
  94. package/dist/worker/shims/zero-process-env.d.ts.map +1 -0
  95. package/dist/worker/shims/zero-process-env.js +9 -0
  96. package/dist/worker/shims/zero-process-env.js.map +1 -0
  97. package/dist/worker/zero-cache-embed-cf.d.ts +29 -12
  98. package/dist/worker/zero-cache-embed-cf.d.ts.map +1 -1
  99. package/dist/worker/zero-cache-embed-cf.js +83 -14
  100. package/dist/worker/zero-cache-embed-cf.js.map +1 -1
  101. package/package.json +11 -2
  102. package/src/cf-do/.wrangler/cache/cf.json +1 -0
  103. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
  104. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
  105. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
  106. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/0ffaabee41a60e04dd0eb7db3073f0a40139e6a97ccd26823967acb652b89a7b.sqlite +0 -0
  107. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite +0 -0
  108. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-shm +0 -0
  109. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-wal +0 -0
  110. package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-insertion-facade.js +11 -0
  111. package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-loader.entry.ts +134 -0
  112. package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-insertion-facade.js +11 -0
  113. package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-loader.entry.ts +134 -0
  114. package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js +1059 -0
  115. package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js.map +8 -0
  116. package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js +1059 -0
  117. package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js.map +8 -0
  118. package/src/cf-do/ARCHITECTURE.md +93 -0
  119. package/src/cf-do/CHAT_E2E.md +213 -0
  120. package/src/cf-do/watermark.test.ts +103 -0
  121. package/src/cf-do/watermark.ts +118 -0
  122. package/src/cf-do/worker.ts +1041 -0
  123. package/src/cf-do/wrangler.toml +11 -0
  124. package/src/cli.test.ts +3 -1
  125. package/src/config.ts +1 -1
  126. package/src/do-sql-tracking.test.ts +19 -0
  127. package/src/do-sql-tracking.ts +19 -0
  128. package/src/index.ts +29 -14
  129. package/src/pg-proxy-browser.ts +6 -6
  130. package/src/pg-proxy-do-backend.test.ts +3890 -0
  131. package/src/pg-proxy-do-backend.ts +6833 -482
  132. package/src/pg-sqlite-compiler/README.md +53 -0
  133. package/src/pg-sqlite-compiler/catalog/seed.ts +524 -0
  134. package/src/pg-sqlite-compiler/fixtures/pgsqlite/arithmetic.json +307 -0
  135. package/src/pg-sqlite-compiler/fixtures/pgsqlite/array.json +377 -0
  136. package/src/pg-sqlite-compiler/fixtures/pgsqlite/cast.json +12 -0
  137. package/src/pg-sqlite-compiler/fixtures/pgsqlite/catalog.json +447 -0
  138. package/src/pg-sqlite-compiler/fixtures/pgsqlite/create-table.json +32 -0
  139. package/src/pg-sqlite-compiler/fixtures/pgsqlite/datetime.json +397 -0
  140. package/src/pg-sqlite-compiler/fixtures/pgsqlite/enum.json +337 -0
  141. package/src/pg-sqlite-compiler/fixtures/pgsqlite/insert.json +337 -0
  142. package/src/pg-sqlite-compiler/fixtures/pgsqlite/json.json +537 -0
  143. package/src/pg-sqlite-compiler/fixtures/pgsqlite/misc.json +1837 -0
  144. package/src/pg-sqlite-compiler/index.ts +73 -0
  145. package/src/pg-sqlite-compiler/integration.test.ts +136 -0
  146. package/src/pg-sqlite-compiler/passes/ast-utils.ts +113 -0
  147. package/src/pg-sqlite-compiler/passes/catalog.ts +65 -0
  148. package/src/pg-sqlite-compiler/passes/datetime.ts +74 -0
  149. package/src/pg-sqlite-compiler/passes/index.ts +49 -0
  150. package/src/pg-sqlite-compiler/passes/types.ts +156 -0
  151. package/src/pg-sqlite-compiler/smoke.test.ts +69 -0
  152. package/src/pg-sqlite-compiler/test/catalog.test.ts +171 -0
  153. package/src/pg-sqlite-compiler/test/corpus.test.ts +161 -0
  154. package/src/pg-sqlite-compiler/test/datetime.oracle.test.ts +102 -0
  155. package/src/pg-sqlite-compiler/test/oracle.ts +237 -0
  156. package/src/pg-sqlite-compiler/test/types.test.ts +109 -0
  157. package/src/pg-sqlite-compiler/types.ts +63 -0
  158. package/src/replication/change-tracker.ts +16 -1
  159. package/src/replication/handler.test.ts +35 -0
  160. package/src/replication/handler.ts +7 -2
  161. package/src/replication/pgoutput-encoder.test.ts +71 -2
  162. package/src/replication/pgoutput-encoder.ts +65 -30
  163. package/src/worker/browser-build-config.test.ts +12 -0
  164. package/src/worker/browser-build-config.ts +2 -1
  165. package/src/worker/cf-patches.ts +274 -4
  166. package/src/worker/shims/node-stub.ts +53 -1
  167. package/src/worker/shims/oxfmt.ts +3 -0
  168. package/src/worker/shims/postgres-socket.ts +1 -1
  169. package/src/worker/shims/sqlite.test.ts +145 -0
  170. package/src/worker/shims/sqlite.ts +256 -9
  171. package/src/worker/shims/ws.ts +45 -0
  172. package/src/worker/shims/zero-process-env.ts +11 -0
  173. package/src/worker/zero-cache-embed-cf.ts +114 -18
  174. package/src/query-rewrites.test.ts +0 -30
  175. 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,400 @@ 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
+ // Only wrap in ctx.storage.transaction() when the call has change-tracking
298
+ // side effects (executeSQL writes BOTH the user table AND _zero_changes,
299
+ // which must commit together to keep source-tab sync flicker-free). A
300
+ // bare /exec is single-statement and ctx.storage.sql already serializes;
301
+ // the transaction wrap was adding ~2-5ms × every call, which on chat's
302
+ // 27k-stmt boot pushed orez backend startup past chat's 60s wait-for-port.
303
+ const result = body.track
304
+ ? await this.ctx.storage.transaction(() => this.executeSQL(sql, params, body.track))
305
+ : this.executeSQL(sql, params);
306
+ return Response.json(result);
307
+ }
308
+ catch (err) {
309
+ const suffix = sql ? ` while executing: ${sqlErrorSnippet(sql, err.message)}` : '';
310
+ console.error(`[exec-500] ${err.message} :: SQL=${sql.slice(0, 800)}`);
311
+ return Response.json({ error: `${err.message}${suffix}` }, { status: 500 });
312
+ }
313
+ }
314
+ /** Execute multiple statements atomically via ctx.storage.transaction() */
315
+ async handleBatch(request) {
316
+ try {
317
+ const { statements } = (await request.json());
318
+ const allRows = await this.ctx.storage.transaction(() => {
319
+ const results = [];
320
+ for (const statement of statements) {
321
+ const item = typeof statement === 'string' ? { sql: statement } : statement;
322
+ if (!item?.sql?.trim())
323
+ continue;
324
+ try {
325
+ results.push(this.executeSQL(item.sql, Array.isArray(item.params) ? item.params : [], item.track));
326
+ }
327
+ catch (err) {
328
+ throw new Error(`${err.message} while executing: ${sqlErrorSnippet(item.sql, err.message)}`);
329
+ }
215
330
  }
331
+ return results;
332
+ });
333
+ return Response.json({ results: allRows });
334
+ }
335
+ catch (err) {
336
+ return Response.json({ error: err.message }, { status: 500 });
337
+ }
338
+ }
339
+ async handleChanges(request, url) {
340
+ try {
341
+ let watermark = Number(url.searchParams.get('watermark') ?? url.searchParams.get('since') ?? 0);
342
+ let limit = Number(url.searchParams.get('limit') ?? 1000);
343
+ if (request.method === 'POST') {
344
+ const body = (await request.json().catch(() => ({})));
345
+ watermark = Number(body.watermark ?? body.since ?? watermark);
346
+ limit = Number(body.limit ?? limit);
216
347
  }
217
- return Response.json({ ok: true });
348
+ if (!Number.isFinite(watermark) || watermark < 0)
349
+ watermark = 0;
350
+ if (!Number.isFinite(limit) || limit <= 0)
351
+ limit = 1000;
352
+ return Response.json({
353
+ watermark: this.watermark(),
354
+ changes: this.readChangesSince(watermark).slice(0, Math.min(limit, 10_000)),
355
+ });
218
356
  }
219
357
  catch (err) {
220
358
  return Response.json({ error: err.message }, { status: 500 });
221
359
  }
222
360
  }
223
- // ── apply a single CRUD mutation ────────────────────────────────────
361
+ executeSQL(sql, params = [], track) {
362
+ const cursor = this.sql.exec(sql, ...params);
363
+ const columns = Array.isArray(cursor.columnNames) ? cursor.columnNames : [];
364
+ const rows = this.cursorRows(cursor);
365
+ if (!track)
366
+ return { rows, columns };
367
+ for (const row of rows) {
368
+ const trackedRow = trackedChangeRow(row, track);
369
+ if (track.operation === 'DELETE')
370
+ this.appendTrackedChange(track.tableName, 'DELETE', null, trackedRow);
371
+ else
372
+ this.appendTrackedChange(track.tableName, track.operation, trackedRow, null);
373
+ }
374
+ return {
375
+ rows: track.returnRows ? rows : [],
376
+ columns: track.returnRows ? columns : [],
377
+ affectedRows: rows.length,
378
+ };
379
+ }
380
+ cursorRows(cursor) {
381
+ return cursor.toArray().map((row) => {
382
+ const obj = {};
383
+ for (const k of Object.keys(row))
384
+ obj[k] = row[k];
385
+ return obj;
386
+ });
387
+ }
388
+ // ── CRUD operations ──────────────────────────────────────────────────────
389
+ applyMutation(mutation) {
390
+ if (mutation.type === 'crud' && mutation.name === '_zero_crud') {
391
+ return this.applyCrudMutation(mutation);
392
+ }
393
+ if (mutation.name === '_zero_cleanupResults')
394
+ return {};
395
+ if (mutation.type === 'custom')
396
+ return this.applyTableMutation(mutation);
397
+ return {
398
+ error: 'app',
399
+ message: `unsupported mutation ${mutation.type}:${mutation.name}`,
400
+ };
401
+ }
402
+ applyTableMutation(mutation) {
403
+ const [tableName, action] = this.tableActionFromMutationName(mutation.name);
404
+ if (!tableName || !action)
405
+ return { error: 'app', message: `invalid mutation name ${mutation.name}` };
406
+ if (!this.tableExists(tableName))
407
+ return { error: 'app', message: `unknown table ${tableName}` };
408
+ const value = (mutation.args[0] || {});
409
+ const primaryKey = this.primaryKeyForTable(tableName, []);
410
+ if (action === 'insert')
411
+ this.insertRow(tableName, value, primaryKey);
412
+ else if (action === 'upsert')
413
+ this.upsertRow(tableName, value, primaryKey);
414
+ else if (action === 'delete')
415
+ this.deleteRow(tableName, value, primaryKey);
416
+ else
417
+ this.updateRow(tableName, value, primaryKey);
418
+ return {};
419
+ }
420
+ tableActionFromMutationName(name) {
421
+ if (name.includes('|'))
422
+ return name.split('|', 2);
423
+ return name.split('.', 2);
424
+ }
425
+ tableNameFromOperationName(name) {
426
+ if (!name)
427
+ return null;
428
+ return name.split(/[.|]/, 1)[0] || null;
429
+ }
224
430
  applyCrudMutation(mutation) {
225
431
  const arg = mutation.args[0];
226
432
  const ops = Array.isArray(arg?.ops) ? arg.ops : [];
227
433
  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;
434
+ if (!crud?.tableName)
435
+ return { error: 'app', message: 'invalid crud mutation' };
436
+ if (!this.tableExists(crud.tableName))
437
+ return { error: 'app', message: `unknown table ${crud.tableName}` };
233
438
  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
- }
439
+ const primaryKey = this.primaryKeyForTable(crud.tableName, crud.primaryKey || []);
440
+ if (crud.op === 'insert')
441
+ this.insertRow(crud.tableName, value, primaryKey);
442
+ else if (crud.op === 'upsert')
443
+ this.upsertRow(crud.tableName, value, primaryKey);
444
+ else if (crud.op === 'update')
445
+ this.updateRow(crud.tableName, value, primaryKey);
446
+ else if (crud.op === 'delete')
447
+ this.deleteRow(crud.tableName, value, primaryKey);
448
+ }
449
+ return {};
450
+ }
451
+ insertRow(tn, value, pk) {
452
+ if (this.readRowByPrimaryKey(tn, value, pk))
453
+ return;
454
+ const row = this.storageRow(tn, value, true);
455
+ const cols = Object.keys(row);
456
+ if (!cols.length)
457
+ return;
458
+ const qc = cols.map((c) => quoteIdent(c)).join(', ');
459
+ const ph = cols.map(() => '?').join(', ');
460
+ this.sql.exec(`INSERT INTO ${quoteIdent(tn)} (${qc}) VALUES (${ph})`, ...cols.map((c) => row[c]));
461
+ const next = this.readRowByPrimaryKey(tn, value, pk) || this.normalizeRow(tn, row);
462
+ this.appendChange(tn, 'INSERT', next, null);
463
+ }
464
+ upsertRow(tn, value, pk) {
465
+ const existing = this.readRowByPrimaryKey(tn, value, pk);
466
+ if (existing) {
467
+ this.updateRow(tn, value, pk);
468
+ return;
243
469
  }
470
+ this.insertRow(tn, value, pk);
244
471
  }
245
- // ── row operations ──────────────────────────────────────────────────
246
- upsertRow(tableName, value) {
247
- const columns = Object.keys(value);
248
- if (columns.length === 0)
472
+ updateRow(tn, value, pk) {
473
+ if (!pk.length)
249
474
  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)
475
+ const existing = this.readRowByPrimaryKey(tn, value, pk);
476
+ if (!existing)
259
477
  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);
478
+ const nk = Object.keys(value).filter((c) => !pk.includes(c));
479
+ if (!nk.length)
480
+ return;
481
+ const storage = this.storageRow(tn, value, false);
482
+ 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])));
483
+ const next = this.readRowByPrimaryKey(tn, value, pk);
484
+ if (next)
485
+ this.appendChange(tn, 'UPDATE', next, existing);
264
486
  }
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);
487
+ deleteRow(tn, value, pk) {
488
+ if (!pk.length)
489
+ return;
490
+ const existing = this.readRowByPrimaryKey(tn, value, pk);
491
+ if (!existing)
492
+ return;
493
+ this.sql.exec(`DELETE FROM ${quoteIdent(tn)} WHERE ${this.primaryKeyWhere(pk)}`, ...pk.map((c) => this.storageColumnValue(tn, c, value[c])));
494
+ this.appendChange(tn, 'DELETE', null, existing);
269
495
  }
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);
496
+ appendChange(tn, op, rowData, oldData) {
497
+ this.appendTrackedChange(tn, op, rowData, oldData);
498
+ }
499
+ appendTrackedChange(tableName, op, rowData, oldData) {
500
+ this.watermarks.ensureTables();
501
+ const watermark = this.watermarks.next();
502
+ 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);
503
+ this.watermarks.mark(watermark);
273
504
  }
274
505
  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) => ({
506
+ this.watermarks.ensureTables();
507
+ return this.sql
508
+ .exec('SELECT watermark, table_name, op, row_data, old_data FROM _zero_changes WHERE watermark > ? ORDER BY watermark', watermark)
509
+ .toArray()
510
+ .map((row) => ({
277
511
  watermark: Number(row.watermark),
278
512
  tableName: String(row.table_name),
279
513
  op: String(row.op),
@@ -281,160 +515,307 @@ class ZeroDO extends cloudflare_workers_1.DurableObject {
281
515
  oldData: row.old_data ? JSON.parse(String(row.old_data)) : null,
282
516
  }));
283
517
  }
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
- `);
518
+ watermark() {
519
+ return this.watermarks.current();
303
520
  }
304
521
  ensureSchemaTables(clientSchema) {
522
+ this.ensureSchemaMetadataTable();
305
523
  for (const [name, def] of Object.entries(clientSchema.tables)) {
524
+ this.tableSchemas.set(name, def);
525
+ this.sql.exec('INSERT OR REPLACE INTO _zero_schema_tables (name, schema_json) VALUES (?, ?)', name, JSON.stringify(def));
306
526
  if (this.schemaTables.has(name))
307
527
  continue;
308
- this.createTable(name, def);
528
+ const pk = def.primaryKey.map((c) => quoteIdent(c));
529
+ const pkClause = pk.length ? `, PRIMARY KEY (${pk.join(', ')})` : '';
530
+ const colDefs = Object.entries(def.columns).map(([cn, cd]) => {
531
+ const t = {
532
+ string: 'TEXT',
533
+ number: 'REAL',
534
+ boolean: 'INTEGER',
535
+ json: 'TEXT',
536
+ bigint: 'TEXT',
537
+ };
538
+ return `${quoteIdent(cn)} ${t[cd.type] || 'TEXT'}`;
539
+ });
540
+ this.sql.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(name)} (${colDefs.join(', ')}${pkClause})`);
309
541
  this.schemaTables.add(name);
310
542
  }
311
543
  }
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})`);
544
+ ensureSchemaMetadataTable() {
545
+ this.sql.exec('CREATE TABLE IF NOT EXISTS _zero_schema_tables (name TEXT PRIMARY KEY, schema_json TEXT NOT NULL)');
320
546
  }
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';
547
+ schemaForTable(tableName) {
548
+ const cached = this.tableSchemas.get(tableName);
549
+ if (cached)
550
+ return cached;
551
+ try {
552
+ this.ensureSchemaMetadataTable();
553
+ const row = this.sql
554
+ .exec('SELECT schema_json FROM _zero_schema_tables WHERE name = ?', tableName)
555
+ .one();
556
+ if (!row?.schema_json)
557
+ return undefined;
558
+ const schema = JSON.parse(String(row.schema_json));
559
+ this.tableSchemas.set(tableName, schema);
560
+ return schema;
561
+ }
562
+ catch {
563
+ return undefined;
329
564
  }
330
565
  }
331
- tableExists(name) {
566
+ tableExists(n) {
332
567
  try {
333
- const row = this.sql.exec(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, name).one();
334
- return !!row;
568
+ return !!this.sql
569
+ .exec("SELECT name FROM sqlite_master WHERE type='table' AND name=?", n)
570
+ .one();
335
571
  }
336
572
  catch {
337
573
  return false;
338
574
  }
339
575
  }
340
- readAllRows(tableName) {
576
+ readAllRows(tn) {
341
577
  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
- });
578
+ return this.sql
579
+ .exec(`SELECT * FROM ${quoteIdent(tn)}`)
580
+ .toArray()
581
+ .map((row) => this.normalizeRow(tn, row));
351
582
  }
352
583
  catch {
353
584
  return [];
354
585
  }
355
586
  }
356
- // ── poke helpers ────────────────────────────────────────────────────
587
+ readRowByPrimaryKey(tn, value, pk) {
588
+ if (!pk.length)
589
+ return null;
590
+ try {
591
+ const row = this.sql
592
+ .exec(`SELECT * FROM ${quoteIdent(tn)} WHERE ${this.primaryKeyWhere(pk)}`, ...pk.map((c) => this.storageColumnValue(tn, c, value[c])))
593
+ .one();
594
+ return row ? this.normalizeRow(tn, row) : null;
595
+ }
596
+ catch {
597
+ return null;
598
+ }
599
+ }
600
+ primaryKeyWhere(pk) {
601
+ return pk.map((c) => `${quoteIdent(c)} = ?`).join(' AND ');
602
+ }
603
+ primaryKeyForTable(tn, fallback) {
604
+ const schema = this.schemaForTable(tn);
605
+ if (schema?.primaryKey?.length)
606
+ return schema.primaryKey;
607
+ return fallback;
608
+ }
609
+ storageRow(tn, value, includeMissingSchemaColumns) {
610
+ const schema = this.schemaForTable(tn);
611
+ const row = {};
612
+ if (schema && includeMissingSchemaColumns) {
613
+ for (const column of Object.keys(schema.columns))
614
+ row[column] = this.storageColumnValue(tn, column, value[column] ?? null);
615
+ }
616
+ for (const column of Object.keys(value)) {
617
+ if (value[column] !== undefined)
618
+ row[column] = this.storageColumnValue(tn, column, value[column]);
619
+ }
620
+ return row;
621
+ }
622
+ storageColumnValue(tn, column, value) {
623
+ if (value === undefined || value === null)
624
+ return null;
625
+ const type = this.schemaForTable(tn)?.columns?.[column]?.type;
626
+ if (type === 'boolean')
627
+ return value ? 1 : 0;
628
+ if (type === 'json')
629
+ return typeof value === 'string' ? value : JSON.stringify(value);
630
+ if (type === 'number')
631
+ return Number(value);
632
+ if (type === 'bigint')
633
+ return String(value);
634
+ return value;
635
+ }
636
+ normalizeRow(tn, row) {
637
+ const schema = this.schemaForTable(tn);
638
+ const normalized = {};
639
+ for (const key of Object.keys(row)) {
640
+ const type = schema?.columns?.[key]?.type;
641
+ const value = row[key];
642
+ if (value === null || value === undefined) {
643
+ normalized[key] = null;
644
+ }
645
+ else if (type === 'boolean') {
646
+ normalized[key] =
647
+ value === true || value === 1 || value === '1' || value === 'true';
648
+ }
649
+ else if (type === 'number') {
650
+ normalized[key] = Number(value);
651
+ }
652
+ else if (type === 'json' && typeof value === 'string') {
653
+ try {
654
+ normalized[key] = JSON.parse(value);
655
+ }
656
+ catch {
657
+ normalized[key] = value;
658
+ }
659
+ }
660
+ else {
661
+ normalized[key] = value;
662
+ }
663
+ }
664
+ return normalized;
665
+ }
357
666
  sendSyncPoke(socket, attachment, part) {
358
- const watermark = this.currentWatermark();
359
- const cookie = String(watermark).padStart(20, '0');
667
+ const cookie = this.nextCookie();
360
668
  const pokeID = crypto.randomUUID();
361
- this.sendJSON(socket, ['pokeStart', {
669
+ this.sendJSON(socket, [
670
+ 'pokeStart',
671
+ {
362
672
  pokeID,
363
673
  baseCookie: attachment.cookie,
364
- schemaVersions: { minSupportedVersion: SCHEMA_VERSION, maxSupportedVersion: SCHEMA_VERSION },
674
+ schemaVersions: {
675
+ minSupportedVersion: SCHEMA_VERSION,
676
+ maxSupportedVersion: SCHEMA_VERSION,
677
+ },
365
678
  timestamp: Date.now(),
366
- }]);
679
+ },
680
+ ]);
367
681
  this.sendJSON(socket, ['pokePart', { pokeID, ...part }]);
368
682
  this.sendJSON(socket, ['pokeEnd', { pokeID, cookie }]);
369
- socket.serializeAttachment({ ...attachment, cookie });
683
+ const nextAttachment = { ...attachment, cookie };
684
+ socket.serializeAttachment(nextAttachment);
685
+ return nextAttachment;
370
686
  }
371
687
  broadcastPoke(clientGroupID, part) {
372
- const websockets = this.ctx.getWebSockets();
373
- for (const ws of websockets) {
374
- const socket = ws;
375
- const attachment = socket.deserializeAttachment();
688
+ for (const socket of this.ctx.getWebSockets()) {
689
+ const ws = socket;
690
+ const attachment = this.readSocketAttachment(ws);
376
691
  if (!attachment)
377
692
  continue;
378
- this.sendSyncPoke(socket, attachment, part);
693
+ if (attachment.clientGroupID !== clientGroupID)
694
+ continue;
695
+ this.sendSyncPoke(ws, attachment, part);
379
696
  }
380
697
  }
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 };
698
+ broadcastMutationPoke(sourceAttachment, part) {
699
+ const rowsPatch = part.rowsPatch || [];
700
+ const changedTables = new Set(rowsPatch
701
+ .map((op) => op?.tableName)
702
+ .filter((tableName) => !!tableName));
703
+ const hasLastMutationIDChanges = Object.keys(part.lastMutationIDChanges || {}).length > 0;
704
+ for (const socket of this.ctx.getWebSockets()) {
705
+ const ws = socket;
706
+ const attachment = this.readSocketAttachment(ws);
707
+ if (!attachment)
708
+ continue;
709
+ if (attachment.userID !== sourceAttachment.userID)
710
+ continue;
711
+ const isSourceClientGroup = attachment.clientGroupID === sourceAttachment.clientGroupID;
712
+ const wantsChangedRows = changedTables.size > 0 &&
713
+ attachment.desiredTableNames.some((tableName) => changedTables.has(tableName));
714
+ const nextPart = {};
715
+ if (wantsChangedRows)
716
+ nextPart.rowsPatch = rowsPatch;
717
+ if (isSourceClientGroup && hasLastMutationIDChanges)
718
+ nextPart.lastMutationIDChanges = part.lastMutationIDChanges;
719
+ if (!nextPart.rowsPatch && !nextPart.lastMutationIDChanges)
720
+ continue;
721
+ this.sendSyncPoke(ws, attachment, nextPart);
385
722
  }
386
- return { op: 'put', tableName: change.tableName, value: change.rowData };
387
723
  }
388
- extractID(row) {
389
- // For a simple id column, use it directly
390
- if (row.id !== undefined)
724
+ syncRowPatchFromChange(change) {
725
+ if (change.op === 'DELETE')
726
+ return {
727
+ op: 'del',
728
+ tableName: change.tableName,
729
+ id: this.primaryKeyValue(change.tableName, change.oldData || {}),
730
+ };
731
+ return {
732
+ op: 'put',
733
+ tableName: change.tableName,
734
+ value: this.normalizeRow(change.tableName, change.rowData || {}),
735
+ };
736
+ }
737
+ primaryKeyValue(tableName, row) {
738
+ const pk = this.primaryKeyForTable(tableName, []);
739
+ if (pk.length)
740
+ return Object.fromEntries(pk.map((column) => [column, row[column]]));
741
+ if ('id' in row)
391
742
  return { id: row.id };
392
- // Otherwise use first key
393
- const key = Object.keys(row)[0];
394
- return key ? { [key]: row[key] } : {};
743
+ return row;
395
744
  }
396
- // ── message helpers ─────────────────────────────────────────────────
397
- sendJSON(socket, message) {
398
- socket.send(JSON.stringify(message));
745
+ cookie() {
746
+ return String(this.watermark()).padStart(20, '0');
399
747
  }
400
- parseMessage(data) {
401
- try {
402
- const text = typeof data === 'string' ? data : new TextDecoder().decode(data);
403
- return JSON.parse(text);
404
- }
405
- catch {
748
+ nextCookie() {
749
+ const watermark = this.watermarks.next();
750
+ this.watermarks.mark(watermark);
751
+ return String(watermark).padStart(20, '0');
752
+ }
753
+ readSocketAttachment(socket) {
754
+ const attachment = socket.deserializeAttachment();
755
+ if (!attachment)
406
756
  return null;
757
+ return {
758
+ ...attachment,
759
+ initialized: attachment.initialized === true,
760
+ desiredTableNames: attachment.desiredTableNames || [],
761
+ desiredQueries: attachment.desiredQueries || [],
762
+ };
763
+ }
764
+ sendJSON(socket, msg) {
765
+ try {
766
+ socket.send(JSON.stringify(msg));
407
767
  }
768
+ catch { }
408
769
  }
409
- decodeInitConnection(secProtocol) {
770
+ parseMessage(data) {
410
771
  try {
411
- const decoded = atob(secProtocol);
412
- return JSON.parse(decoded);
772
+ return JSON.parse(typeof data === 'string' ? data : new TextDecoder().decode(data));
413
773
  }
414
774
  catch {
415
775
  return null;
416
776
  }
417
777
  }
418
778
  }
419
- exports.ZeroDO = ZeroDO;
420
- // ── Worker entry ───────────────────────────────────────────────────────
421
- exports.default = {
779
+ export default {
422
780
  async fetch(request, env) {
423
781
  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);
782
+ const id = env.ZERO_DO.idFromName('singleton');
783
+ if (url.pathname.startsWith('/sync/v') && url.pathname.endsWith('/connect')) {
784
+ return env.ZERO_DO.get(id).fetch(request);
785
+ }
786
+ if ((url.pathname === '/zero/push' || url.pathname === '/api/zero/push') &&
787
+ request.method === 'POST') {
788
+ return env.ZERO_DO.get(id).fetch(request);
789
+ }
790
+ if (url.pathname === '/exec' && request.method === 'POST') {
791
+ return env.ZERO_DO.get(id).fetch(request);
792
+ }
793
+ if (url.pathname === '/batch' && request.method === 'POST') {
794
+ return env.ZERO_DO.get(id).fetch(request);
795
+ }
796
+ if (url.pathname === '/changes' &&
797
+ (request.method === 'GET' || request.method === 'POST')) {
798
+ return env.ZERO_DO.get(id).fetch(request);
799
+ }
800
+ if (url.pathname === '/notify' && request.method === 'POST') {
801
+ return env.ZERO_DO.get(id).fetch(request);
436
802
  }
437
803
  return new Response('not found', { status: 404 });
438
804
  },
439
805
  };
806
+ function decodeInitConnection(secProtocol) {
807
+ try {
808
+ const decoded = decodeURIComponent(secProtocol);
809
+ const bytes = Uint8Array.from(atob(decoded), (char) => char.charCodeAt(0));
810
+ const protocols = JSON.parse(new TextDecoder().decode(bytes));
811
+ const message = protocols.initConnectionMessage;
812
+ if (Array.isArray(message) && message[0] === 'initConnection') {
813
+ return message;
814
+ }
815
+ return null;
816
+ }
817
+ catch {
818
+ return null;
819
+ }
820
+ }
440
821
  //# sourceMappingURL=worker.js.map