orez-sync-cf-host 0.5.19 → 0.5.21

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 (52) hide show
  1. package/README.md +49 -6
  2. package/dist/bun-wasm-loader.d.ts +1 -0
  3. package/dist/bun-wasm-loader.js +20 -0
  4. package/dist/config.d.ts +3 -0
  5. package/dist/config.js +93 -0
  6. package/{src → dist}/generated/sync_wasm_bg.wasm +0 -0
  7. package/dist/host.d.ts +12 -0
  8. package/dist/host.js +1512 -0
  9. package/dist/index.d.ts +8 -0
  10. package/dist/index.js +4 -0
  11. package/dist/mutation-error.d.ts +9 -0
  12. package/dist/mutation-error.js +23 -0
  13. package/dist/node-wasm-loader.d.ts +1 -0
  14. package/dist/node-wasm-loader.js +22 -0
  15. package/dist/post-commit.d.ts +13 -0
  16. package/dist/post-commit.js +31 -0
  17. package/dist/query-compiler.d.ts +4 -0
  18. package/dist/query-compiler.js +4 -0
  19. package/dist/sql-storage-adapter.d.ts +69 -0
  20. package/dist/sql-storage-adapter.js +194 -0
  21. package/dist/transaction-query.d.ts +72 -0
  22. package/dist/transaction-query.js +379 -0
  23. package/dist/types.d.ts +159 -0
  24. package/dist/types.js +5 -0
  25. package/dist/vite-wasm-loader.d.ts +3 -0
  26. package/dist/vite-wasm-loader.js +24 -0
  27. package/dist/wasm.d.ts +1 -0
  28. package/dist/wasm.js +7 -0
  29. package/dist/write-safeguards.d.ts +53 -0
  30. package/dist/write-safeguards.js +186 -0
  31. package/package.json +59 -19
  32. package/src/bun-wasm-loader.ts +0 -16
  33. package/src/config.ts +0 -102
  34. package/src/harness-config.ts +0 -487
  35. package/src/harness-worker.ts +0 -33
  36. package/src/host.ts +0 -1958
  37. package/src/index.ts +0 -47
  38. package/src/ingest-harness-worker.ts +0 -535
  39. package/src/mutation-error.ts +0 -28
  40. package/src/platform-probe-worker.ts +0 -418
  41. package/src/post-commit.ts +0 -40
  42. package/src/query-compiler.ts +0 -16
  43. package/src/sql-storage-adapter.ts +0 -254
  44. package/src/transaction-query.ts +0 -494
  45. package/src/types.ts +0 -195
  46. package/src/wasm-platform.ts +0 -10
  47. package/src/wasm.ts +0 -34
  48. package/src/write-safeguards.ts +0 -248
  49. /package/{src → dist}/generated/package.json +0 -0
  50. /package/{src → dist}/generated/sync_wasm.d.ts +0 -0
  51. /package/{src → dist}/generated/sync_wasm.js +0 -0
  52. /package/{src → dist}/generated/sync_wasm_bg.wasm.d.ts +0 -0
package/dist/host.js ADDED
@@ -0,0 +1,1512 @@
1
+ import { DurableObject } from 'cloudflare:workers';
2
+ import { validatePullCaps, validateSyncHostConfig } from './config.js';
3
+ import { createPostCommitEffects } from './post-commit.js';
4
+ import { createQueryCompiler } from './query-compiler.js';
5
+ import { decodeSqlParams, SqlStorageDirect, SqlStorageMutatorTransaction, SqlStorageSyncDb, } from './sql-storage-adapter.js';
6
+ import { isMutationApplicationError } from './types.js';
7
+ import { engine_apply_snapshot_changes, engine_apply_snapshot_page, engine_apply_upstream, engine_assemble_push_response, engine_begin_snapshot_generation, engine_finalize, engine_finalize_snapshot_generation, engine_handle_pull, engine_handle_query_pull, engine_init_query_schema, engine_init_schema, engine_invalidate, engine_memory_bytes, engine_preflight, engine_prune, engine_push_validate, engine_read_snapshot_progress, engine_record_app_error, engine_state, engine_version, } from './wasm.js';
8
+ import { IngestBreakerError, IngestCircuitBreaker, retryDelayMs, shouldRetryDelegatedPush, } from './write-safeguards.js';
9
+ const CLAIMS_HEADER = 'x-orez-sync-claims';
10
+ const NAMESPACE_HEADER = 'x-orez-sync-namespace';
11
+ const UPSTREAM_PATH_HEADER = 'x-orez-sync-upstream-path';
12
+ const DEFAULT_SNAPSHOT_PAGE_ROWS = 2_000;
13
+ const MIN_SNAPSHOT_PAGE_ROWS = 100;
14
+ const DEFAULT_CAPS = {
15
+ maxChangeRows: 10_000,
16
+ maxChangeBytes: 2_000_000,
17
+ };
18
+ function freshCounters() {
19
+ return {
20
+ pulls: 0,
21
+ pushes: 0,
22
+ resets: 0,
23
+ applicationErrors: 0,
24
+ invariantFailures: 0,
25
+ retentionRuns: 0,
26
+ queryRecompilations: 0,
27
+ wasmBoundaryCalls: 0,
28
+ wakeFrames: 0,
29
+ wakeBatches: 0,
30
+ externalEffectFailures: 0,
31
+ };
32
+ }
33
+ function json(value, status = 200) {
34
+ return Response.json(value, {
35
+ status,
36
+ headers: { 'cache-control': 'no-store' },
37
+ });
38
+ }
39
+ function isStructuredPushFailed(value) {
40
+ if (typeof value !== 'object' || value === null)
41
+ return false;
42
+ const body = value;
43
+ return (body.kind === 'PushFailed' &&
44
+ typeof body.origin === 'string' &&
45
+ typeof body.reason === 'string' &&
46
+ typeof body.message === 'string' &&
47
+ Array.isArray(body.mutationIDs));
48
+ }
49
+ function statusOf(error) {
50
+ if (typeof error === 'object' && error !== null && 'status' in error) {
51
+ const status = Number(error.status);
52
+ if (Number.isInteger(status) && status >= 400 && status <= 599)
53
+ return status;
54
+ }
55
+ return 500;
56
+ }
57
+ function errorMessage(error) {
58
+ return error instanceof Error ? error.message : String(error);
59
+ }
60
+ function errorBody(error) {
61
+ if (error instanceof IngestBreakerError) {
62
+ return {
63
+ error: error.error,
64
+ windowRows: error.windowRows,
65
+ budget: error.budget,
66
+ retryAfterMs: error.retryAfterMs,
67
+ };
68
+ }
69
+ return { error: errorMessage(error) };
70
+ }
71
+ function requestError(message, status = 400) {
72
+ return Object.assign(new Error(message), { status });
73
+ }
74
+ async function requestJson(request) {
75
+ try {
76
+ return await request.json();
77
+ }
78
+ catch {
79
+ throw requestError('invalid JSON request body');
80
+ }
81
+ }
82
+ async function requestObject(request) {
83
+ const value = await requestJson(request);
84
+ if (!value || typeof value !== 'object' || Array.isArray(value))
85
+ throw requestError('request body must be a JSON object');
86
+ return value;
87
+ }
88
+ function routeAfterNamespace(pathname) {
89
+ const [, , ...parts] = pathname.split('/');
90
+ return `/${parts.join('/')}`;
91
+ }
92
+ function claimsFromRequest(request) {
93
+ const encoded = request.headers.get(CLAIMS_HEADER);
94
+ if (!encoded)
95
+ return null;
96
+ try {
97
+ const value = JSON.parse(decodeURIComponent(encoded));
98
+ return value && typeof value.userID === 'string' && value.userID.length > 0
99
+ ? value
100
+ : null;
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
106
+ async function namespaceHash(namespace) {
107
+ const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(namespace));
108
+ return Array.from(new Uint8Array(bytes).slice(0, 8), (byte) => byte.toString(16).padStart(2, '0')).join('');
109
+ }
110
+ function socketAttachment(socket) {
111
+ const value = socket.deserializeAttachment();
112
+ return value && typeof value.clientID === 'string' ? value : null;
113
+ }
114
+ // Closing a socket that is already closed/closing throws, and a throw inside a
115
+ // hibernatable WebSocket handler aborts the DO. Swallow it: the socket is going
116
+ // away regardless.
117
+ function socketCloseQuietly(socket, code, reason) {
118
+ try {
119
+ socket.close(code, reason);
120
+ }
121
+ catch {
122
+ // already closing/closed, or workerd rejected the code — nothing to do
123
+ }
124
+ }
125
+ /**
126
+ * Create the consumer-facing Worker router. Authentication happens here; the
127
+ * Durable Object receives only normalized claims over a binding-private header.
128
+ */
129
+ export function createSyncWorker(config) {
130
+ validateSyncHostConfig(config);
131
+ return {
132
+ async fetch(request, env) {
133
+ const namespace = config.namespace(request);
134
+ if (!namespace)
135
+ return new Response('orez sync-cf-host', { status: 200 });
136
+ const route = routeAfterNamespace(new URL(request.url).pathname);
137
+ const isAdmin = route.startsWith('/admin/');
138
+ if (route === '/wake') {
139
+ if (!(await config.authorizeWake(request, env))) {
140
+ return json({ error: 'missing wake capability' }, 401);
141
+ }
142
+ }
143
+ else if (route === '/notify') {
144
+ if (!(await config.authorizeNotify(request, env))) {
145
+ return json({ error: 'forbidden' }, 403);
146
+ }
147
+ }
148
+ else if (isAdmin) {
149
+ const authorized = config.authorizeAdmin
150
+ ? await config.authorizeAdmin(request, env)
151
+ : Boolean(env.ADMIN_KEY) && request.headers.get('x-admin-key') === env.ADMIN_KEY;
152
+ if (!authorized)
153
+ return json({ error: 'forbidden' }, 403);
154
+ }
155
+ const headers = new Headers(request.headers);
156
+ headers.delete(CLAIMS_HEADER);
157
+ headers.delete(NAMESPACE_HEADER);
158
+ headers.delete(UPSTREAM_PATH_HEADER);
159
+ if (!isAdmin && route !== '/wake' && route !== '/notify') {
160
+ const claims = await config.authenticate(request, env);
161
+ if (!claims)
162
+ return json({ error: 'missing authentication' }, 401);
163
+ headers.set(CLAIMS_HEADER, encodeURIComponent(JSON.stringify(claims)));
164
+ }
165
+ headers.set(NAMESPACE_HEADER, await namespaceHash(namespace));
166
+ if (config.upstream) {
167
+ const namespacePath = typeof config.upstream.namespacePath === 'function'
168
+ ? config.upstream.namespacePath(namespace)
169
+ : config.upstream.namespacePath;
170
+ if (!namespacePath.startsWith('/')) {
171
+ return json({ error: 'upstream namespacePath must be an absolute path' }, 500);
172
+ }
173
+ headers.set(UPSTREAM_PATH_HEADER, namespacePath.replace(/\/$/, ''));
174
+ }
175
+ const forwarded = new Request(request, { headers });
176
+ const id = env.SYNC_DO.idFromName(namespace);
177
+ return env.SYNC_DO.get(id).fetch(forwarded);
178
+ },
179
+ };
180
+ }
181
+ /** Create the namespace Durable Object class for one bundled consumer config. */
182
+ export function createSyncDurableObject(config) {
183
+ validateSyncHostConfig(config);
184
+ const compileQuery = createQueryCompiler(config.schema);
185
+ const defaultRetainChanges = String(config.retainChanges ?? 4_096);
186
+ const caps = validatePullCaps({ ...DEFAULT_CAPS, ...config.caps });
187
+ const idleTeardownMs = config.idleTeardownMs ?? 5_000;
188
+ // A CF fan-out wakes every client into an HTTP pull. Give concurrent writer
189
+ // requests a real batching window so a storm burst creates one pull wave.
190
+ const wakeCoalesceMs = config.wakeCoalesceMs ?? 25;
191
+ const upstreamIntervalMs = config.upstream?.intervalMs ?? 15_000;
192
+ const upstreamLimit = config.upstream?.changeLimit ?? 1_000;
193
+ const ingestBudgetRows = config.upstream?.ingestBudgetRows ?? 150_000;
194
+ const ingestBudgetWindowMs = config.upstream?.ingestBudgetWindowMs ?? 5 * 60_000;
195
+ const ingestBackoffMs = config.upstream?.ingestBackoffMs ?? 1_000;
196
+ const ingestMaxBackoffMs = config.upstream?.ingestMaxBackoffMs ?? 60_000;
197
+ const delegateMaxAttempts = config.delegatedPushRetry?.maxAttempts ?? 3;
198
+ const delegateInitialBackoffMs = config.delegatedPushRetry?.initialBackoffMs ?? 100;
199
+ const delegateMaxBackoffMs = config.delegatedPushRetry?.maxBackoffMs ?? 1_000;
200
+ const delegateTimeoutMs = config.delegatedPushRetry?.timeoutMs ?? 5_000;
201
+ return class SyncDurableObject extends DurableObject {
202
+ #engineDb;
203
+ #directSql;
204
+ #mutatorSql;
205
+ #bootID = crypto.randomUUID();
206
+ #lastRequestAt = 0;
207
+ #hibernations = 0;
208
+ #dropNextPushResponse = false;
209
+ #counters = freshCounters();
210
+ #pulling = new Set();
211
+ #wakeOrigins = new Set();
212
+ #wakeRecipients = new Set();
213
+ #wakePromise = null;
214
+ #ingestPromise = null;
215
+ #queryPullLocks = new Map();
216
+ #recordingIngestBillable = false;
217
+ #ingestBreaker = new IngestCircuitBreaker({
218
+ budgetRows: ingestBudgetRows,
219
+ windowMs: ingestBudgetWindowMs,
220
+ initialBackoffMs: ingestBackoffMs,
221
+ maxBackoffMs: ingestMaxBackoffMs,
222
+ now: () => Date.now(),
223
+ });
224
+ constructor(ctx, env) {
225
+ super(ctx, env);
226
+ const recordRowsWritten = (rows) => {
227
+ if (this.#recordingIngestBillable)
228
+ this.#ingestBreaker.recordBillable(rows);
229
+ };
230
+ this.#engineDb = new SqlStorageSyncDb(ctx.storage.sql, recordRowsWritten);
231
+ this.#directSql = new SqlStorageDirect(ctx.storage.sql, recordRowsWritten);
232
+ this.#mutatorSql = new SqlStorageMutatorTransaction(this.#directSql, (ast, format) => this.#wasm(() => compileQuery(ast, format)), config.transactionQueryBudget);
233
+ ctx.blockConcurrencyWhile(async () => {
234
+ ctx.storage.transactionSync(() => {
235
+ config.initialize(this.#directSql);
236
+ this.#directSql.exec(`CREATE TABLE IF NOT EXISTS _zsync_host_control (
237
+ key TEXT PRIMARY KEY,
238
+ value TEXT NOT NULL
239
+ )`);
240
+ this.#directSql.exec("INSERT OR IGNORE INTO _zsync_host_control (key, value) VALUES ('writerEnabled', '1')");
241
+ const ingestBreakerReason = this.#controlGet('ingestBreakerReason');
242
+ if (ingestBreakerReason === 'ingestBudgetExceeded' ||
243
+ ingestBreakerReason === 'ingestCursorStalled') {
244
+ this.#ingestBreaker.restore(ingestBreakerReason, Number(this.#controlGet('ingestBreakerRetryAt')), Number(this.#controlGet('ingestBreakerTrips')));
245
+ }
246
+ this.#wasm(() => engine_init_schema(this.#engineDb, config.schema));
247
+ if (config.queryAware || config.resolveQuery)
248
+ this.#wasm(() => engine_init_query_schema(this.#engineDb));
249
+ });
250
+ });
251
+ }
252
+ #armUpstreamAlarm() {
253
+ if (!config.upstream)
254
+ return;
255
+ this.ctx.waitUntil((async () => {
256
+ if ((await this.ctx.storage.getAlarm()) === null) {
257
+ await this.ctx.storage.setAlarm(Date.now() + upstreamIntervalMs);
258
+ }
259
+ })());
260
+ }
261
+ #wasm(call) {
262
+ this.#counters.wasmBoundaryCalls++;
263
+ return call();
264
+ }
265
+ async #acquireQueryPullLock(clientGroupID) {
266
+ const previous = this.#queryPullLocks.get(clientGroupID);
267
+ let release;
268
+ const current = new Promise((resolve) => {
269
+ release = resolve;
270
+ });
271
+ this.#queryPullLocks.set(clientGroupID, current);
272
+ if (previous)
273
+ await previous;
274
+ return () => {
275
+ release();
276
+ if (this.#queryPullLocks.get(clientGroupID) === current) {
277
+ this.#queryPullLocks.delete(clientGroupID);
278
+ }
279
+ };
280
+ }
281
+ #simulateIdleTeardown(now) {
282
+ if (this.#lastRequestAt > 0 && now - this.#lastRequestAt >= idleTeardownMs) {
283
+ this.#bootID = crypto.randomUUID();
284
+ this.#hibernations++;
285
+ this.#counters = freshCounters();
286
+ this.#pulling.clear();
287
+ this.#wakeOrigins.clear();
288
+ this.#wakeRecipients.clear();
289
+ this.#wakePromise = null;
290
+ }
291
+ this.#lastRequestAt = now;
292
+ }
293
+ #visibility(claims) {
294
+ if (!config.visibility || !this.#visibilityEnabled())
295
+ return null;
296
+ return {
297
+ rowLocal: typeof config.visibility.rowLocal === 'function'
298
+ ? config.visibility.rowLocal(claims)
299
+ : config.visibility.rowLocal,
300
+ filters: Object.keys(config.schema.tables).flatMap((table) => {
301
+ const filter = config.visibility?.filter(table, claims);
302
+ return filter
303
+ ? [{ table, sql: filter.sql, params: [...(filter.params ?? [])] }]
304
+ : [];
305
+ }),
306
+ };
307
+ }
308
+ // the admin-set namespace knobs (writer, visibility, query-aware,
309
+ // retention) live in _zsync_host_control, NOT in instance fields: a real
310
+ // eviction recreates this instance, and an in-memory override silently
311
+ // reverting to the config default mid-run turns a query-aware namespace
312
+ // back into a baseline one — the client keeps re-sending its desired-query
313
+ // patch, the host ignores it, and every pull answers {unchanged:true}
314
+ // forever.
315
+ #controlGet(key) {
316
+ const row = this.#directSql.query('SELECT value FROM _zsync_host_control WHERE key = ?', [key])[0];
317
+ return row?.value ?? null;
318
+ }
319
+ #controlSet(key, value) {
320
+ this.#directSql.exec('INSERT INTO _zsync_host_control (key, value) VALUES (?, ?) ' +
321
+ 'ON CONFLICT(key) DO UPDATE SET value = excluded.value', [key, value]);
322
+ }
323
+ #controlDelete(...keys) {
324
+ if (keys.length === 0)
325
+ return;
326
+ this.#directSql.exec(`DELETE FROM _zsync_host_control WHERE key IN (${keys.map(() => '?').join(', ')})`, keys);
327
+ }
328
+ #persistIngestBreaker() {
329
+ const status = this.#ingestBreaker.status();
330
+ if (!status.reason || status.retryAt === null)
331
+ return;
332
+ this.#controlSet('ingestBreakerReason', status.reason);
333
+ this.#controlSet('ingestBreakerRetryAt', String(status.retryAt));
334
+ this.#controlSet('ingestBreakerTrips', String(status.consecutiveTrips));
335
+ }
336
+ #recoverIngestBreaker() {
337
+ const wasTripped = this.#ingestBreaker.status().reason !== null;
338
+ this.#ingestBreaker.recovered();
339
+ if (wasTripped) {
340
+ this.#controlDelete('ingestBreakerReason', 'ingestBreakerRetryAt', 'ingestBreakerTrips');
341
+ }
342
+ }
343
+ #writerEnabled() {
344
+ return this.#controlGet('writerEnabled') === '1';
345
+ }
346
+ #visibilityEnabled() {
347
+ const value = this.#controlGet('visibilityEnabled');
348
+ return value === null ? (config.visibilityEnabled ?? false) : value === '1';
349
+ }
350
+ #queryAwareOverride() {
351
+ const value = this.#controlGet('queryAwareOverride');
352
+ return value === null ? null : value === '1';
353
+ }
354
+ #retainChanges() {
355
+ return this.#controlGet('retainChanges') ?? defaultRetainChanges;
356
+ }
357
+ #takeFault(point) {
358
+ if (this.#controlGet('faultPoint') !== point)
359
+ return null;
360
+ const kind = this.#controlGet('faultKind');
361
+ this.#directSql.exec("DELETE FROM _zsync_host_control WHERE key IN ('faultPoint', 'faultKind')");
362
+ return kind === 'quota' ? 'quota' : 'error';
363
+ }
364
+ #faultError(kind, point) {
365
+ return requestError(`injected ${kind} fault at ${point}`, kind === 'quota' ? 507 : 500);
366
+ }
367
+ #engineState() {
368
+ return this.#wasm(() => engine_state(this.#engineDb));
369
+ }
370
+ #engineStateBestEffort() {
371
+ try {
372
+ return this.#engineState();
373
+ }
374
+ catch {
375
+ return null;
376
+ }
377
+ }
378
+ #serviceBinding(name = config.upstream?.binding) {
379
+ const value = name
380
+ ? this.env[name]
381
+ : undefined;
382
+ if (!value || typeof value.fetch !== 'function') {
383
+ throw requestError(`missing upstream service binding: ${name ?? '(not configured)'}`, 500);
384
+ }
385
+ return value;
386
+ }
387
+ async #upstreamWriteBudgetStatus() {
388
+ if (!config.upstream)
389
+ return json({ error: 'upstream is not configured' }, 404);
390
+ const path = this.#controlGet('upstreamPath');
391
+ if (path === null)
392
+ return json({ error: 'upstream path is not known yet' }, 409);
393
+ const endpoint = new URL(`${path}/_orez/write-budget`, 'https://upstream.invalid');
394
+ const response = await this.#serviceBinding().fetch(endpoint.toString(), {
395
+ headers: { host: endpoint.host },
396
+ });
397
+ if (!response.ok) {
398
+ return json({
399
+ error: 'upstream write-budget status unavailable',
400
+ upstreamStatus: response.status,
401
+ }, 502);
402
+ }
403
+ return json(await response.json());
404
+ }
405
+ #rememberUpstreamPath(request) {
406
+ if (!config.upstream)
407
+ return null;
408
+ const path = request.headers.get(UPSTREAM_PATH_HEADER);
409
+ if (path !== null) {
410
+ this.#controlSet('upstreamPath', path);
411
+ return path;
412
+ }
413
+ return this.#controlGet('upstreamPath');
414
+ }
415
+ #tripIngest(reason, fields) {
416
+ try {
417
+ return this.#ingestBreaker.trip(reason);
418
+ }
419
+ catch (error) {
420
+ this.#persistIngestBreaker();
421
+ const status = this.#ingestBreaker.status();
422
+ console.error(JSON.stringify({
423
+ event: 'sync_upstream_ingest_breaker_tripped',
424
+ ...status,
425
+ reason,
426
+ ...fields,
427
+ }));
428
+ throw error;
429
+ }
430
+ }
431
+ #recordIngestLogicalRows(rows) {
432
+ this.#ingestBreaker.recordLogical(rows);
433
+ }
434
+ #withIngestBilling(fields, apply) {
435
+ this.#recordingIngestBillable = true;
436
+ try {
437
+ return apply();
438
+ }
439
+ catch (error) {
440
+ this.#recordingIngestBillable = false;
441
+ const status = this.#ingestBreaker.status();
442
+ // a breaker thrown by the sql adapter crosses rust as an engine error,
443
+ // so the durable breaker state is the authoritative classification.
444
+ if (error instanceof IngestBreakerError ||
445
+ (status.tripped && status.reason === 'ingestBudgetExceeded')) {
446
+ this.#persistIngestBreaker();
447
+ console.error(JSON.stringify({
448
+ event: 'sync_upstream_ingest_breaker_tripped',
449
+ ...status,
450
+ reason: 'ingestBudgetExceeded',
451
+ ...fields,
452
+ }));
453
+ }
454
+ throw error;
455
+ }
456
+ finally {
457
+ this.#recordingIngestBillable = false;
458
+ }
459
+ }
460
+ #snapshotProgress() {
461
+ return this.#wasm(() => engine_read_snapshot_progress(this.#engineDb));
462
+ }
463
+ #resetSnapshotBillingWindow() {
464
+ // every page is an independently committed write unit. metering it in a
465
+ // fresh window keeps a rebuild larger than the breaker ceiling resumable
466
+ // while preserving the ceiling for each transaction.
467
+ this.#ingestBreaker.reopen();
468
+ this.#controlDelete('ingestBreakerReason', 'ingestBreakerRetryAt', 'ingestBreakerTrips');
469
+ }
470
+ #snapshotRetryLimit(error, limit, fields) {
471
+ const status = statusOf(error);
472
+ if (!(error instanceof IngestBreakerError) && status < 500)
473
+ throw error;
474
+ if (limit <= MIN_SNAPSHOT_PAGE_ROWS) {
475
+ throw Object.assign(new Error(`snapshot page failed at minimum limit ${MIN_SNAPSHOT_PAGE_ROWS}: ${errorMessage(error)}`), { status, cause: error });
476
+ }
477
+ const nextLimit = Math.max(MIN_SNAPSHOT_PAGE_ROWS, Math.floor(limit / 2));
478
+ console.warn(JSON.stringify({
479
+ event: 'sync_upstream_snapshot_page_retry',
480
+ ...fields,
481
+ limit,
482
+ nextLimit,
483
+ status,
484
+ error: errorMessage(error),
485
+ }));
486
+ return nextLimit;
487
+ }
488
+ async #fetchSnapshotPage(path, table, cursor, limit) {
489
+ const endpoint = new URL(`${path}/snapshot`, 'https://upstream.invalid');
490
+ endpoint.searchParams.set('table', table);
491
+ endpoint.searchParams.set('limit', String(limit));
492
+ if (cursor !== null)
493
+ endpoint.searchParams.set('cursor', cursor);
494
+ const response = await this.#serviceBinding().fetch(endpoint.toString(), {
495
+ headers: { host: endpoint.host },
496
+ });
497
+ if (!response.ok) {
498
+ throw requestError(`upstream snapshot page returned ${response.status}`, response.status >= 500 ? 502 : response.status);
499
+ }
500
+ const page = (await response.json());
501
+ if (!Number.isSafeInteger(page.watermark) ||
502
+ Number(page.watermark) < 0 ||
503
+ !Array.isArray(page.rows) ||
504
+ (page.nextCursor !== null && typeof page.nextCursor !== 'string')) {
505
+ throw new Error('invalid upstream snapshot page response');
506
+ }
507
+ return page;
508
+ }
509
+ async #beginSnapshotGeneration(path) {
510
+ const table = Object.keys(config.schema.tables).sort()[0];
511
+ if (!table)
512
+ throw requestError('paged snapshots require a modeled table', 500);
513
+ let pageLimit = DEFAULT_SNAPSHOT_PAGE_ROWS;
514
+ let page;
515
+ for (;;) {
516
+ try {
517
+ page = await this.#fetchSnapshotPage(path, table, null, pageLimit);
518
+ break;
519
+ }
520
+ catch (error) {
521
+ pageLimit = this.#snapshotRetryLimit(error, pageLimit, {
522
+ phase: 'snapshot_page_fetch',
523
+ table,
524
+ cursor: null,
525
+ });
526
+ }
527
+ }
528
+ this.#resetSnapshotBillingWindow();
529
+ const progress = this.#withIngestBilling({
530
+ phase: 'snapshot_begin',
531
+ table,
532
+ startWatermark: page.watermark,
533
+ }, () => this.ctx.storage.transactionSync(() => this.#wasm(() => engine_begin_snapshot_generation(this.#engineDb, config.schema, String(page.watermark)))));
534
+ return { progress, page, pageLimit };
535
+ }
536
+ #ingest(upstreamPath, forceSnapshot = false) {
537
+ if (!config.upstream) {
538
+ return forceSnapshot
539
+ ? Promise.reject(requestError('upstream is not configured'))
540
+ : Promise.resolve(0);
541
+ }
542
+ if (this.#ingestPromise) {
543
+ return forceSnapshot
544
+ ? this.#ingestPromise.then(() => this.#ingest(upstreamPath, true))
545
+ : this.#ingestPromise;
546
+ }
547
+ const path = upstreamPath ?? this.#controlGet('upstreamPath');
548
+ if (path === null) {
549
+ return forceSnapshot
550
+ ? Promise.reject(requestError('upstream path is not available'))
551
+ : Promise.resolve(0);
552
+ }
553
+ this.#ingestPromise = (async () => {
554
+ let progress = this.#snapshotProgress();
555
+ this.#ingestBreaker.assertReady();
556
+ const startingWatermark = this.#engineState().watermark;
557
+ let total = 0;
558
+ let pendingPage = null;
559
+ let snapshotPageLimit = DEFAULT_SNAPSHOT_PAGE_ROWS;
560
+ let snapshotCompleted = false;
561
+ for (;;) {
562
+ if (progress?.state === 'paging') {
563
+ const activeProgress = progress;
564
+ const table = activeProgress.table;
565
+ if (table === null) {
566
+ throw new Error(`snapshot generation ${activeProgress.generation} is paging without a table`);
567
+ }
568
+ let page = pendingPage;
569
+ try {
570
+ page ??= await this.#fetchSnapshotPage(path, table, activeProgress.cursor, snapshotPageLimit);
571
+ const pageToApply = page;
572
+ this.#resetSnapshotBillingWindow();
573
+ const nextProgress = this.#withIngestBilling({
574
+ phase: 'snapshot_page_apply',
575
+ generation: activeProgress.generation,
576
+ table,
577
+ cursor: activeProgress.cursor,
578
+ pageRows: pageToApply.rows.length,
579
+ pageLimit: snapshotPageLimit,
580
+ }, () => this.ctx.storage.transactionSync(() => this.#wasm(() => engine_apply_snapshot_page(this.#engineDb, config.schema, activeProgress.generation, table, pageToApply.rows, pageToApply.nextCursor))));
581
+ total += pageToApply.rows.length;
582
+ this.#recordIngestLogicalRows(pageToApply.rows.length);
583
+ progress = nextProgress;
584
+ pendingPage = null;
585
+ }
586
+ catch (error) {
587
+ snapshotPageLimit = this.#snapshotRetryLimit(error, snapshotPageLimit, {
588
+ phase: page === null ? 'snapshot_page_fetch' : 'snapshot_page_apply',
589
+ generation: activeProgress.generation,
590
+ table,
591
+ cursor: activeProgress.cursor,
592
+ });
593
+ pendingPage = null;
594
+ }
595
+ continue;
596
+ }
597
+ if (progress?.state === 'catching_up') {
598
+ const activeProgress = progress;
599
+ const cursor = activeProgress.catchupWatermark;
600
+ const endpoint = new URL(`${path}/changes`, 'https://upstream.invalid');
601
+ endpoint.searchParams.set('since', cursor);
602
+ endpoint.searchParams.set('limit', String(upstreamLimit));
603
+ const response = await this.#serviceBinding().fetch(endpoint.toString(), {
604
+ headers: { host: endpoint.host },
605
+ });
606
+ if (response.status === 410) {
607
+ const begun = await this.#beginSnapshotGeneration(path);
608
+ progress = begun.progress;
609
+ pendingPage = begun.page;
610
+ snapshotPageLimit = begun.pageLimit;
611
+ continue;
612
+ }
613
+ if (!response.ok) {
614
+ throw new Error(`upstream snapshot catch-up returned ${response.status}`);
615
+ }
616
+ const batch = (await response.json());
617
+ if (!Number.isSafeInteger(batch.watermark) || !Array.isArray(batch.changes)) {
618
+ throw new Error('invalid upstream changes response');
619
+ }
620
+ this.#resetSnapshotBillingWindow();
621
+ const result = this.#withIngestBilling({
622
+ phase: 'snapshot_catchup',
623
+ generation: activeProgress.generation,
624
+ cursor,
625
+ batchWatermark: batch.watermark,
626
+ changeRows: batch.changes.length,
627
+ }, () => this.ctx.storage.transactionSync(() => this.#wasm(() => engine_apply_snapshot_changes(this.#engineDb, config.schema, activeProgress.generation, batch))));
628
+ total += result.applied;
629
+ this.#recordIngestLogicalRows(result.applied);
630
+ if (result.caughtUp) {
631
+ this.#resetSnapshotBillingWindow();
632
+ this.#withIngestBilling({
633
+ phase: 'snapshot_finalize',
634
+ generation: activeProgress.generation,
635
+ watermark: result.watermark,
636
+ }, () => this.ctx.storage.transactionSync(() => this.#wasm(() => engine_finalize_snapshot_generation(this.#engineDb, config.schema, activeProgress.generation, String(result.watermark)))));
637
+ progress = null;
638
+ snapshotCompleted = true;
639
+ break;
640
+ }
641
+ if (String(result.watermark) === cursor) {
642
+ this.#tripIngest('ingestCursorStalled', {
643
+ phase: 'snapshot_catchup',
644
+ generation: activeProgress.generation,
645
+ cursor,
646
+ batchWatermark: batch.watermark,
647
+ changeRows: batch.changes.length,
648
+ applied: result.applied,
649
+ });
650
+ }
651
+ progress = {
652
+ ...progress,
653
+ catchupWatermark: String(result.watermark),
654
+ };
655
+ continue;
656
+ }
657
+ const cursor = this.#engineState().upstreamWatermark;
658
+ if (forceSnapshot) {
659
+ forceSnapshot = false;
660
+ const begun = await this.#beginSnapshotGeneration(path);
661
+ progress = begun.progress;
662
+ pendingPage = begun.page;
663
+ snapshotPageLimit = begun.pageLimit;
664
+ continue;
665
+ }
666
+ const endpoint = new URL(`${path}/changes`, 'https://upstream.invalid');
667
+ endpoint.searchParams.set('watermark', cursor);
668
+ endpoint.searchParams.set('limit', String(upstreamLimit));
669
+ const response = await this.#serviceBinding().fetch(endpoint.toString(), {
670
+ headers: { host: endpoint.host },
671
+ });
672
+ if (response.status === 410) {
673
+ const begun = await this.#beginSnapshotGeneration(path);
674
+ progress = begun.progress;
675
+ pendingPage = begun.page;
676
+ snapshotPageLimit = begun.pageLimit;
677
+ continue;
678
+ }
679
+ if (!response.ok) {
680
+ throw new Error(`upstream changes returned ${response.status}`);
681
+ }
682
+ const batch = (await response.json());
683
+ if (!Number.isSafeInteger(batch.watermark) || !Array.isArray(batch.changes)) {
684
+ throw new Error('invalid upstream changes response');
685
+ }
686
+ const result = this.#withIngestBilling({
687
+ phase: 'changes',
688
+ cursor,
689
+ batchWatermark: batch.watermark,
690
+ changeRows: batch.changes.length,
691
+ }, () => this.ctx.storage.transactionSync(() => this.#wasm(() => engine_apply_upstream(this.#engineDb, config.schema, batch))));
692
+ total += result.applied;
693
+ this.#recordIngestLogicalRows(result.applied);
694
+ const nextCursor = this.#engineState().upstreamWatermark;
695
+ if (batch.changes.length > 0 && String(nextCursor) === String(cursor)) {
696
+ this.#tripIngest('ingestCursorStalled', {
697
+ phase: 'changes',
698
+ cursor,
699
+ batchWatermark: batch.watermark,
700
+ changeRows: batch.changes.length,
701
+ applied: result.applied,
702
+ });
703
+ }
704
+ if (result.caughtUp)
705
+ break;
706
+ // a page can legitimately apply zero rows while still advancing the
707
+ // watermark: the engine consumes changes for tables this host does not
708
+ // model (subset replica) without materializing them. only a page that
709
+ // neither applied nor advanced is genuinely stalled.
710
+ if (result.applied === 0 && String(nextCursor) === String(cursor)) {
711
+ this.#tripIngest('ingestCursorStalled', {
712
+ phase: 'changes',
713
+ cursor,
714
+ batchWatermark: batch.watermark,
715
+ changeRows: batch.changes.length,
716
+ applied: result.applied,
717
+ });
718
+ }
719
+ }
720
+ this.#recoverIngestBreaker();
721
+ const endingWatermark = this.#engineState().watermark;
722
+ if (snapshotCompleted || total > 0 || endingWatermark !== startingWatermark) {
723
+ await this.#enqueueWake('__upstream__');
724
+ }
725
+ return total;
726
+ })().finally(() => {
727
+ this.#ingestPromise = null;
728
+ });
729
+ return this.#ingestPromise;
730
+ }
731
+ #ingestAfterCurrent(upstreamPath) {
732
+ const current = this.#ingestPromise;
733
+ return current
734
+ ? current.then(() => this.#ingest(upstreamPath))
735
+ : this.#ingest(upstreamPath);
736
+ }
737
+ async #fetchDelegatedPush(endpoint, headers, body, provisioning = false) {
738
+ const binding = this.#serviceBinding(config.mutateBinding ?? config.upstream?.binding);
739
+ let lastError = null;
740
+ for (let attempt = 1; attempt <= delegateMaxAttempts; attempt++) {
741
+ let response = null;
742
+ try {
743
+ response = await binding.fetch(endpoint.toString(), {
744
+ method: 'POST',
745
+ headers,
746
+ body,
747
+ signal: AbortSignal.timeout(provisioning ? Math.max(delegateTimeoutMs, 25_000) : delegateTimeoutMs),
748
+ });
749
+ }
750
+ catch (error) {
751
+ lastError = error;
752
+ }
753
+ if (!shouldRetryDelegatedPush(response?.status ?? null, attempt, delegateMaxAttempts)) {
754
+ if (response)
755
+ return response;
756
+ throw lastError;
757
+ }
758
+ await response?.body?.cancel();
759
+ const delayMs = retryDelayMs(attempt, delegateInitialBackoffMs, delegateMaxBackoffMs);
760
+ console.warn(JSON.stringify({
761
+ event: 'sync_delegated_push_retry',
762
+ attempt,
763
+ maxAttempts: delegateMaxAttempts,
764
+ status: response?.status ?? null,
765
+ delayMs,
766
+ error: response ? null : errorMessage(lastError),
767
+ }));
768
+ await scheduler.wait(delayMs);
769
+ }
770
+ throw lastError ?? new Error('delegated push retry exhausted');
771
+ }
772
+ #log(fields) {
773
+ console.log(JSON.stringify({
774
+ event: 'sync_request',
775
+ hostVersion: config.hostVersion,
776
+ engineVersion: engine_version(),
777
+ ...fields,
778
+ }));
779
+ }
780
+ #enqueueWake(originClientID) {
781
+ this.#wakeOrigins.add(originClientID);
782
+ for (const socket of this.ctx.getWebSockets()) {
783
+ const attachment = socketAttachment(socket);
784
+ if (!attachment ||
785
+ attachment.clientID === originClientID ||
786
+ this.#pulling.has(attachment.clientID)) {
787
+ continue;
788
+ }
789
+ this.#wakeRecipients.add(socket);
790
+ }
791
+ return this.#scheduleWake();
792
+ }
793
+ #scheduleWake() {
794
+ if (!this.#wakePromise) {
795
+ const queuedAt = performance.now();
796
+ this.#wakePromise = (async () => {
797
+ await scheduler.wait(wakeCoalesceMs);
798
+ const fanoutStarted = performance.now();
799
+ const origins = this.#wakeOrigins;
800
+ this.#wakeOrigins = new Set();
801
+ const recipients = this.#wakeRecipients;
802
+ this.#wakeRecipients = new Set();
803
+ this.#counters.wakeBatches++;
804
+ let sent = 0;
805
+ const sockets = this.ctx.getWebSockets();
806
+ for (const socket of recipients) {
807
+ try {
808
+ socket.send('wake');
809
+ sent++;
810
+ this.#counters.wakeFrames++;
811
+ }
812
+ catch {
813
+ // A closing hibernating socket disappears from getWebSockets;
814
+ // a race here is advisory and carries no correctness weight.
815
+ }
816
+ }
817
+ console.log(JSON.stringify({
818
+ event: 'sync_wake',
819
+ hostVersion: config.hostVersion,
820
+ socketCount: sockets.length,
821
+ originCount: origins.size,
822
+ sent,
823
+ eligibleRecipients: recipients.size,
824
+ coalesceMs: fanoutStarted - queuedAt,
825
+ fanoutMs: performance.now() - fanoutStarted,
826
+ }));
827
+ })().finally(() => {
828
+ this.#wakePromise = null;
829
+ if (this.#wakeOrigins.size > 0)
830
+ void this.#scheduleWake();
831
+ });
832
+ }
833
+ return this.#wakePromise;
834
+ }
835
+ async #pull(request, claims, namespace) {
836
+ this.#counters.pulls++;
837
+ this.#engineDb.resetStats();
838
+ const started = performance.now();
839
+ let transactionMs = 0;
840
+ let body;
841
+ try {
842
+ body = await requestObject(request);
843
+ const queryAware = this.#queryAwareOverride() ??
844
+ (typeof config.queryAware === 'function'
845
+ ? config.queryAware(claims)
846
+ : (config.queryAware ?? Boolean(config.resolveQuery)));
847
+ const transformVersion = queryAware
848
+ ? typeof config.queryTransformVersion === 'function'
849
+ ? config.queryTransformVersion(claims)
850
+ : (config.queryTransformVersion ?? 0)
851
+ : 0;
852
+ if (!Number.isSafeInteger(transformVersion) || transformVersion < 0) {
853
+ throw new TypeError('queryTransformVersion must be a non-negative safe integer');
854
+ }
855
+ const releaseQueryPull = queryAware && config.resolveQuery
856
+ ? await this.#acquireQueryPullLock(typeof body.clientGroupID === 'string' ? body.clientGroupID : '')
857
+ : null;
858
+ let response;
859
+ try {
860
+ if (queryAware && body.queries) {
861
+ const queries = body.queries;
862
+ if (Array.isArray(queries.patch)) {
863
+ const patch = [];
864
+ for (const operation of queries.patch) {
865
+ if (!operation || typeof operation !== 'object') {
866
+ patch.push(operation);
867
+ continue;
868
+ }
869
+ const op = operation;
870
+ if (op.op === 'put') {
871
+ if (!config.resolveQuery || typeof op.name !== 'string') {
872
+ throw requestError('query put requires a server-resolved named query');
873
+ }
874
+ if (!Array.isArray(op.args)) {
875
+ throw requestError('named query args must be an array');
876
+ }
877
+ const args = op.args;
878
+ let ast;
879
+ try {
880
+ // resolveQuery may be async and needs `env` (a consumer can
881
+ // delegate the transform to its app's real synced-queries
882
+ // endpoint over an app service binding — authenticate runs in the
883
+ // worker isolate, but the query loop runs here in the DO, so the
884
+ // binding must come from the DO's own env, not a shared global).
885
+ ast = await config.resolveQuery(op.name, args, claims, this.env);
886
+ }
887
+ catch (error) {
888
+ throw requestError(`unknown or unsupported named query: ${op.name}`);
889
+ }
890
+ patch.push({
891
+ op: 'put',
892
+ hash: op.hash,
893
+ ast,
894
+ transformVersion,
895
+ });
896
+ }
897
+ else
898
+ patch.push(operation);
899
+ }
900
+ body = { ...body, queries: { ...queries, patch } };
901
+ }
902
+ }
903
+ if (queryAware) {
904
+ body = { ...body, _serverQueryTransformVersion: transformVersion };
905
+ }
906
+ const clientID = typeof body.clientID === 'string' ? body.clientID : '';
907
+ this.#pulling.add(clientID);
908
+ try {
909
+ const txStarted = performance.now();
910
+ const duringFault = this.#takeFault('pull_during_tx');
911
+ response = this.ctx.storage.transactionSync(() => {
912
+ const result = this.#wasm(() => queryAware
913
+ ? engine_handle_query_pull(this.#engineDb, config.schema, this.#retainChanges(), body, claims.userID)
914
+ : engine_handle_pull(this.#engineDb, config.schema, this.#visibility(claims), caps, this.#retainChanges(), body, claims.userID));
915
+ if (duringFault)
916
+ throw this.#faultError(duringFault, 'pull_during_tx');
917
+ return result;
918
+ });
919
+ transactionMs = performance.now() - txStarted;
920
+ }
921
+ finally {
922
+ this.#pulling.delete(clientID);
923
+ }
924
+ }
925
+ finally {
926
+ releaseQueryPull?.();
927
+ }
928
+ const afterPullFault = this.#takeFault('pull_after_commit');
929
+ if (afterPullFault)
930
+ throw this.#faultError(afterPullFault, 'pull_after_commit');
931
+ const patch = Array.isArray(response.rowsPatch) ? response.rowsPatch : [];
932
+ const queriesBody = body.queries;
933
+ const queryPuts = queryAware && Array.isArray(queriesBody?.patch)
934
+ ? queriesBody.patch.filter((entry) => entry?.op === 'put').length
935
+ : 0;
936
+ this.#counters.queryRecompilations += queryPuts;
937
+ const state = this.#engineStateBestEffort();
938
+ this.#log({
939
+ namespaceHash: namespace,
940
+ requestKind: 'pull',
941
+ resultClass: response.unchanged === true ? 'unchanged' : 'success',
942
+ inputCookie: body.cookie ?? null,
943
+ outputCookie: response.cookie ?? null,
944
+ retainedFloor: state?.floor ?? null,
945
+ currentWatermark: state?.watermark ?? null,
946
+ changeRowsScanned: null,
947
+ changeRowsIncluded: null,
948
+ queriesRecomputed: queryPuts,
949
+ rowPuts: patch.filter((entry) => entry?.op === 'put').length,
950
+ rowDeletes: patch.filter((entry) => entry?.op === 'del').length,
951
+ lmidAdvances: 0,
952
+ transactionMs,
953
+ totalMs: performance.now() - started,
954
+ resetReason: null,
955
+ wasmBoundaryCalls: this.#counters.wasmBoundaryCalls,
956
+ sql: this.#engineDb.stats,
957
+ });
958
+ return json(response);
959
+ }
960
+ catch (error) {
961
+ const status = statusOf(error);
962
+ if (status === 409)
963
+ this.#counters.resets++;
964
+ if (status === 500)
965
+ this.#counters.invariantFailures++;
966
+ const state = this.#engineStateBestEffort();
967
+ this.#log({
968
+ namespaceHash: namespace,
969
+ requestKind: 'pull',
970
+ resultClass: status === 409 ? 'reset' : 'error',
971
+ inputCookie: body?.cookie ?? null,
972
+ outputCookie: null,
973
+ retainedFloor: state?.floor ?? null,
974
+ currentWatermark: state?.watermark ?? null,
975
+ changeRowsScanned: null,
976
+ changeRowsIncluded: 0,
977
+ queriesRecomputed: 0,
978
+ rowPuts: 0,
979
+ rowDeletes: 0,
980
+ lmidAdvances: 0,
981
+ transactionMs,
982
+ totalMs: performance.now() - started,
983
+ resetReason: status === 409 ? errorMessage(error) : null,
984
+ });
985
+ return json({ error: errorMessage(error) }, status);
986
+ }
987
+ }
988
+ async #push(request, claims, namespace, upstreamPath) {
989
+ this.#counters.pushes++;
990
+ this.#engineDb.resetStats();
991
+ const started = performance.now();
992
+ let transactionMs = 0;
993
+ let lmidAdvances = 0;
994
+ let resultClass = 'success';
995
+ if (!this.#writerEnabled()) {
996
+ // Workerd requires the request stream to be consumed before the DO
997
+ // returns a response. Discard it without parsing or logging payloads.
998
+ await request.arrayBuffer();
999
+ const state = this.#engineStateBestEffort();
1000
+ this.#log({
1001
+ namespaceHash: namespace,
1002
+ requestKind: 'push',
1003
+ resultClass: 'writer_disabled',
1004
+ inputCookie: null,
1005
+ outputCookie: null,
1006
+ retainedFloor: state?.floor ?? null,
1007
+ currentWatermark: state?.watermark ?? null,
1008
+ changeRowsScanned: 0,
1009
+ changeRowsIncluded: 0,
1010
+ queriesRecomputed: 0,
1011
+ rowPuts: 0,
1012
+ rowDeletes: 0,
1013
+ lmidAdvances: 0,
1014
+ transactionMs: 0,
1015
+ totalMs: performance.now() - started,
1016
+ resetReason: 'writer disabled by operator',
1017
+ wasmBoundaryCalls: this.#counters.wasmBoundaryCalls,
1018
+ sql: this.#engineDb.stats,
1019
+ });
1020
+ return json({ error: 'writer disabled by operator' }, 503);
1021
+ }
1022
+ if (config.mutateUrl) {
1023
+ try {
1024
+ const bytes = await request.arrayBuffer();
1025
+ const body = JSON.parse(new TextDecoder().decode(bytes));
1026
+ const plan = this.#wasm(() => engine_push_validate(body));
1027
+ if (plan.kind === 'respond')
1028
+ return json(plan.response);
1029
+ const endpoint = new URL(`${upstreamPath ?? ''}${config.mutateUrl}`, config.mutateOrigin ?? 'https://upstream.invalid');
1030
+ const headers = new Headers(request.headers);
1031
+ headers.delete(CLAIMS_HEADER);
1032
+ headers.delete(NAMESPACE_HEADER);
1033
+ headers.delete(UPSTREAM_PATH_HEADER);
1034
+ headers.set('host', endpoint.host);
1035
+ const upstreamResponse = await this.#fetchDelegatedPush(endpoint, headers, bytes, this.#engineState().upstreamWatermark === '0');
1036
+ if (!upstreamResponse.ok) {
1037
+ return new Response(upstreamResponse.body, upstreamResponse);
1038
+ }
1039
+ const upstreamBody = (await upstreamResponse.json());
1040
+ const delegatedResponse = upstreamBody.pushResponse ?? upstreamBody;
1041
+ if (isStructuredPushFailed(delegatedResponse)) {
1042
+ // PushFailed is a successful protocol response describing an
1043
+ // application-level failure. There are intentionally no mutation
1044
+ // acknowledgements to finalize in the host; preserve the body so
1045
+ // the Zero client can apply its retry/error policy.
1046
+ return json({ pushResponse: delegatedResponse });
1047
+ }
1048
+ const acknowledged = typeof upstreamBody.pushResponse === 'object' &&
1049
+ upstreamBody.pushResponse !== null &&
1050
+ 'mutations' in upstreamBody.pushResponse
1051
+ ? upstreamBody.pushResponse.mutations
1052
+ : upstreamBody.mutations;
1053
+ if (!Array.isArray(acknowledged)) {
1054
+ throw new Error('delegated push returned no mutation results');
1055
+ }
1056
+ for (const mutation of plan.mutations) {
1057
+ const ack = acknowledged.some((result) => result.id?.clientID === mutation.clientID &&
1058
+ String(result.id?.id) === mutation.id);
1059
+ if (!ack) {
1060
+ throw new Error(`delegated push did not acknowledge ${mutation.clientID}:${mutation.id}`);
1061
+ }
1062
+ }
1063
+ // the delegated app response is causally visible through DATA by
1064
+ // contract. start an ingest round after that response, even if an
1065
+ // older round is still in flight, then journal lmids. every capped
1066
+ // log prefix therefore preserves effects-before-ack.
1067
+ await this.#ingestAfterCurrent(upstreamPath);
1068
+ for (const mutation of plan.mutations) {
1069
+ this.ctx.storage.transactionSync(() => {
1070
+ const decision = this.#wasm(() => engine_preflight(this.#engineDb, plan.clientGroupID, mutation.clientID, mutation.id, claims.userID));
1071
+ if (decision.kind === 'applied') {
1072
+ this.#wasm(() => engine_finalize(this.#engineDb, plan.clientGroupID, mutation.clientID, mutation.id));
1073
+ lmidAdvances++;
1074
+ }
1075
+ });
1076
+ }
1077
+ if (lmidAdvances > 0) {
1078
+ this.ctx.storage.transactionSync(() => this.#wasm(() => engine_prune(this.#engineDb, this.#retainChanges())));
1079
+ }
1080
+ return json({ pushResponse: delegatedResponse });
1081
+ }
1082
+ catch (error) {
1083
+ const status = statusOf(error);
1084
+ return json(errorBody(error), status);
1085
+ }
1086
+ }
1087
+ try {
1088
+ const body = await requestObject(request);
1089
+ const beforeMutationFault = this.#takeFault('push_before_mutation');
1090
+ if (beforeMutationFault)
1091
+ throw this.#faultError(beforeMutationFault, 'push_before_mutation');
1092
+ const plan = this.#wasm(() => engine_push_validate(body));
1093
+ if (plan.kind === 'respond')
1094
+ return json(plan.response);
1095
+ const results = [];
1096
+ for (const mutation of plan.mutations) {
1097
+ const deferred = createPostCommitEffects();
1098
+ try {
1099
+ const txStarted = performance.now();
1100
+ // consume the before-commit fault OUTSIDE the transaction it is
1101
+ // about to abort: taken inside, the control-table delete rolls
1102
+ // back with the abort and the fault re-fires on every retry
1103
+ // instead of being one-shot.
1104
+ const beforeCommitFault = this.#takeFault('push_after_write_before_commit');
1105
+ const preflight = await this.ctx.storage.transaction(async () => {
1106
+ // Storage transactions may retry their closure. Never carry a
1107
+ // deferred effect from an abandoned attempt into the commit.
1108
+ deferred.beginAttempt();
1109
+ const decision = this.#wasm(() => engine_preflight(this.#engineDb, plan.clientGroupID, mutation.clientID, mutation.id, claims.userID));
1110
+ if (decision.kind === 'replay')
1111
+ return decision;
1112
+ const mutator = config.mutators?.[mutation.name];
1113
+ if (!mutator)
1114
+ throw new Error(`unknown mutator: ${mutation.name}`);
1115
+ await mutator(this.#mutatorSql, mutation.args[0] ?? null, {
1116
+ claims,
1117
+ clientID: mutation.clientID,
1118
+ mutationID: mutation.id,
1119
+ defer: deferred.defer,
1120
+ });
1121
+ if (beforeCommitFault)
1122
+ throw this.#faultError(beforeCommitFault, 'push_after_write_before_commit');
1123
+ this.#wasm(() => engine_finalize(this.#engineDb, plan.clientGroupID, mutation.clientID, mutation.id));
1124
+ return decision;
1125
+ });
1126
+ transactionMs += performance.now() - txStarted;
1127
+ if (preflight.kind === 'replay') {
1128
+ const expected = preflight
1129
+ .expected;
1130
+ results.push({
1131
+ clientID: mutation.clientID,
1132
+ id: mutation.id,
1133
+ result: {
1134
+ error: 'alreadyProcessed',
1135
+ details: `Ignoring mutation from ${mutation.clientID} with ID ${mutation.id} as it was already processed. Expected: ${expected}`,
1136
+ },
1137
+ });
1138
+ continue;
1139
+ }
1140
+ lmidAdvances++;
1141
+ results.push({
1142
+ clientID: mutation.clientID,
1143
+ id: mutation.id,
1144
+ result: {},
1145
+ });
1146
+ // Keep the advisory fan-out off the push response's critical path.
1147
+ // waitUntil anchors the coalescing timer across request completion,
1148
+ // while the next serialized client push can join the same batch.
1149
+ this.ctx.waitUntil(this.#enqueueWake(mutation.clientID));
1150
+ await deferred.runAfterCommit((error) => {
1151
+ this.#counters.externalEffectFailures++;
1152
+ console.error(JSON.stringify({
1153
+ event: 'sync_external_effect_error',
1154
+ hostVersion: config.hostVersion,
1155
+ error: errorMessage(error),
1156
+ }));
1157
+ });
1158
+ }
1159
+ catch (error) {
1160
+ const isAppError = isMutationApplicationError(error);
1161
+ if (!isAppError)
1162
+ throw error;
1163
+ this.#counters.applicationErrors++;
1164
+ resultClass = 'application_error';
1165
+ const appError = error;
1166
+ const txStarted = performance.now();
1167
+ await this.ctx.storage.transaction(async () => {
1168
+ this.#wasm(() => engine_record_app_error(this.#engineDb, plan.clientGroupID, mutation.clientID, mutation.id, claims.userID));
1169
+ });
1170
+ transactionMs += performance.now() - txStarted;
1171
+ lmidAdvances++;
1172
+ results.push({
1173
+ clientID: mutation.clientID,
1174
+ id: mutation.id,
1175
+ result: {
1176
+ error: 'app',
1177
+ message: appError.message,
1178
+ details: appError.details ?? appError.message,
1179
+ },
1180
+ });
1181
+ this.ctx.waitUntil(this.#enqueueWake(mutation.clientID));
1182
+ }
1183
+ }
1184
+ if (plan.mutations.length > 0) {
1185
+ const txStarted = performance.now();
1186
+ await this.ctx.storage.transaction(async () => {
1187
+ this.#wasm(() => engine_prune(this.#engineDb, this.#retainChanges()));
1188
+ });
1189
+ transactionMs += performance.now() - txStarted;
1190
+ this.#counters.retentionRuns++;
1191
+ }
1192
+ const afterCommitFault = this.#takeFault('push_after_commit_before_response');
1193
+ if (afterCommitFault)
1194
+ throw this.#faultError(afterCommitFault, 'push_after_commit_before_response');
1195
+ const response = this.#wasm(() => engine_assemble_push_response(results));
1196
+ const state = this.#engineStateBestEffort();
1197
+ this.#log({
1198
+ namespaceHash: namespace,
1199
+ requestKind: 'push',
1200
+ resultClass,
1201
+ inputCookie: null,
1202
+ outputCookie: null,
1203
+ retainedFloor: state?.floor ?? null,
1204
+ currentWatermark: state?.watermark ?? null,
1205
+ changeRowsScanned: 0,
1206
+ changeRowsIncluded: 0,
1207
+ queriesRecomputed: 0,
1208
+ rowPuts: 0,
1209
+ rowDeletes: 0,
1210
+ lmidAdvances,
1211
+ transactionMs,
1212
+ totalMs: performance.now() - started,
1213
+ resetReason: null,
1214
+ wasmBoundaryCalls: this.#counters.wasmBoundaryCalls,
1215
+ sql: this.#engineDb.stats,
1216
+ });
1217
+ if (this.#dropNextPushResponse) {
1218
+ this.#dropNextPushResponse = false;
1219
+ return json({ error: 'intentionally dropped push response' }, 503);
1220
+ }
1221
+ return json(response);
1222
+ }
1223
+ catch (error) {
1224
+ const status = statusOf(error);
1225
+ if (status === 500)
1226
+ this.#counters.invariantFailures++;
1227
+ const state = this.#engineStateBestEffort();
1228
+ this.#log({
1229
+ namespaceHash: namespace,
1230
+ requestKind: 'push',
1231
+ resultClass: 'error',
1232
+ inputCookie: null,
1233
+ outputCookie: null,
1234
+ retainedFloor: state?.floor ?? null,
1235
+ currentWatermark: state?.watermark ?? null,
1236
+ changeRowsScanned: 0,
1237
+ changeRowsIncluded: 0,
1238
+ queriesRecomputed: 0,
1239
+ rowPuts: 0,
1240
+ rowDeletes: 0,
1241
+ lmidAdvances,
1242
+ transactionMs,
1243
+ totalMs: performance.now() - started,
1244
+ resetReason: null,
1245
+ });
1246
+ return json({ error: errorMessage(error) }, status);
1247
+ }
1248
+ }
1249
+ #wake(request) {
1250
+ if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
1251
+ return json({ error: 'websocket upgrade required' }, 426);
1252
+ }
1253
+ const clientID = new URL(request.url).searchParams.get('clientID');
1254
+ if (!clientID)
1255
+ return json({ error: 'clientID is required' }, 400);
1256
+ const pair = new WebSocketPair();
1257
+ const [client, server] = Object.values(pair);
1258
+ server.serializeAttachment({ clientID });
1259
+ this.ctx.acceptWebSocket(server, [`client:${clientID}`]);
1260
+ // The alarm is only a safety net for an actively connected consumer.
1261
+ // A namespace with no wake socket has nobody to notify; its next pull or
1262
+ // push ingests synchronously. Arming from construction made every
1263
+ // namespace poll upstream forever after its first request, even across
1264
+ // DO eviction, producing a permanent rows-written floor at zero traffic.
1265
+ this.#armUpstreamAlarm();
1266
+ return new Response(null, { status: 101, webSocket: client });
1267
+ }
1268
+ #admin(route, request, upstreamPath) {
1269
+ if (route === '/admin/health')
1270
+ return json({ ok: true });
1271
+ if (route === '/admin/upstream-write-budget' && request.method === 'GET')
1272
+ return this.#upstreamWriteBudgetStatus();
1273
+ if (route === '/admin/status') {
1274
+ const heap = performance.memory;
1275
+ return this.ctx.storage.getAlarm().then((upstreamAlarmAt) => json({
1276
+ bootID: this.#bootID,
1277
+ idleTeardownMs,
1278
+ hibernations: this.#hibernations,
1279
+ databaseSizeBytes: this.ctx.storage.sql.databaseSize,
1280
+ connectedWakeSockets: this.ctx.getWebSockets().length,
1281
+ upstreamAlarmAt,
1282
+ writerEnabled: this.#writerEnabled(),
1283
+ wasmMemoryBytes: engine_memory_bytes(),
1284
+ heapUsedBytes: heap?.usedJSHeapSize ?? null,
1285
+ heapTotalBytes: heap?.totalJSHeapSize ?? null,
1286
+ heapLimitBytes: heap?.jsHeapSizeLimit ?? null,
1287
+ engine: this.#engineStateBestEffort(),
1288
+ counters: this.#counters,
1289
+ ingestBreaker: this.#ingestBreaker.status(),
1290
+ }));
1291
+ }
1292
+ if (route === '/admin/sql') {
1293
+ return request.json().then((body) => {
1294
+ const { params, query } = body;
1295
+ if (typeof query !== 'string')
1296
+ return json({ error: 'query is required' }, 400);
1297
+ try {
1298
+ return json({ rows: this.#directSql.query(query, decodeSqlParams(params)) });
1299
+ }
1300
+ catch (error) {
1301
+ if (error instanceof TypeError && error.message.startsWith('params')) {
1302
+ return json({ error: `invalid params: ${error.message}` }, 400);
1303
+ }
1304
+ if (error instanceof TypeError &&
1305
+ error.message === 'transaction SQL is host-owned and forbidden') {
1306
+ return json({ error: error.message }, 400);
1307
+ }
1308
+ throw error;
1309
+ }
1310
+ });
1311
+ }
1312
+ if (route === '/admin/invalidate') {
1313
+ this.ctx.storage.transactionSync(() => this.#wasm(() => engine_invalidate(this.#engineDb)));
1314
+ return json({ ok: true, engine: this.#engineState() });
1315
+ }
1316
+ if (route === '/admin/resnapshot') {
1317
+ if (request.method !== 'POST') {
1318
+ return json({ error: 'method not allowed' }, 405);
1319
+ }
1320
+ return (async () => {
1321
+ try {
1322
+ const beforeUpstreamWatermark = this.#engineState().upstreamWatermark;
1323
+ const applied = await this.#ingest(upstreamPath, true);
1324
+ const engine = this.#engineState();
1325
+ return json({
1326
+ ok: true,
1327
+ applied,
1328
+ beforeUpstreamWatermark,
1329
+ afterUpstreamWatermark: engine.upstreamWatermark,
1330
+ engine,
1331
+ });
1332
+ }
1333
+ catch (error) {
1334
+ return json(errorBody(error), statusOf(error));
1335
+ }
1336
+ })();
1337
+ }
1338
+ if (route === '/admin/drop-next-push-response') {
1339
+ this.#dropNextPushResponse = true;
1340
+ return json({ ok: true });
1341
+ }
1342
+ if (route === '/admin/restart') {
1343
+ this.ctx.abort('admin requested durable object restart');
1344
+ return json({ ok: true, bootID: this.#bootID });
1345
+ }
1346
+ if (route === '/admin/visibility') {
1347
+ return request.json().then((body) => {
1348
+ const enabled = Boolean(body.enabled);
1349
+ this.#controlSet('visibilityEnabled', enabled ? '1' : '0');
1350
+ return json({ ok: true, enabled });
1351
+ });
1352
+ }
1353
+ if (route === '/admin/query-aware') {
1354
+ return request.json().then((body) => {
1355
+ const enabled = Boolean(body.enabled);
1356
+ this.#controlSet('queryAwareOverride', enabled ? '1' : '0');
1357
+ return json({ ok: true, enabled });
1358
+ });
1359
+ }
1360
+ if (route === '/admin/retention') {
1361
+ return request
1362
+ .json()
1363
+ .catch(() => ({}))
1364
+ .then((body) => {
1365
+ const value = Number(body.retainChanges);
1366
+ if (!Number.isSafeInteger(value) || value < 0)
1367
+ return json({ error: 'invalid retainChanges' }, 400);
1368
+ this.#controlSet('retainChanges', String(value));
1369
+ return json({ ok: true, retainChanges: value });
1370
+ });
1371
+ }
1372
+ if (route === '/admin/writer') {
1373
+ if (request.method === 'GET')
1374
+ return json({ writerEnabled: this.#writerEnabled() });
1375
+ return request
1376
+ .json()
1377
+ .catch(() => ({}))
1378
+ .then((body) => {
1379
+ const enabled = body.enabled;
1380
+ if (typeof enabled !== 'boolean')
1381
+ return json({ error: 'enabled must be a boolean' }, 400);
1382
+ this.#controlSet('writerEnabled', enabled ? '1' : '0');
1383
+ return json({ ok: true, writerEnabled: enabled });
1384
+ });
1385
+ }
1386
+ if (route === '/admin/ingest-breaker') {
1387
+ if (request.method === 'GET')
1388
+ return json(this.#ingestBreaker.status());
1389
+ this.#ingestBreaker.reopen();
1390
+ this.#controlDelete('ingestBreakerReason', 'ingestBreakerRetryAt', 'ingestBreakerTrips');
1391
+ console.log(JSON.stringify({ event: 'sync_upstream_ingest_breaker_reopened' }));
1392
+ return json({ ok: true, ...this.#ingestBreaker.status() });
1393
+ }
1394
+ if (route === '/admin/fault') {
1395
+ return request
1396
+ .json()
1397
+ .catch(() => ({}))
1398
+ .then((body) => {
1399
+ const value = body;
1400
+ if (value.clear === true) {
1401
+ this.#directSql.exec("DELETE FROM _zsync_host_control WHERE key IN ('faultPoint', 'faultKind')");
1402
+ return json({ ok: true, armed: null });
1403
+ }
1404
+ const points = [
1405
+ 'push_before_mutation',
1406
+ 'push_after_write_before_commit',
1407
+ 'push_after_commit_before_response',
1408
+ 'pull_during_tx',
1409
+ 'pull_after_commit',
1410
+ ];
1411
+ if (!points.includes(value.point))
1412
+ return json({ error: 'invalid fault point' }, 400);
1413
+ if (value.kind !== 'error' && value.kind !== 'quota')
1414
+ return json({ error: 'invalid fault kind' }, 400);
1415
+ this.#controlSet('faultPoint', value.point);
1416
+ this.#controlSet('faultKind', value.kind);
1417
+ return json({
1418
+ ok: true,
1419
+ armed: { point: value.point, kind: value.kind },
1420
+ });
1421
+ });
1422
+ }
1423
+ return json({ error: 'not found' }, 404);
1424
+ }
1425
+ async fetch(request) {
1426
+ this.#simulateIdleTeardown(Date.now());
1427
+ const route = routeAfterNamespace(new URL(request.url).pathname);
1428
+ const namespace = request.headers.get(NAMESPACE_HEADER) ?? 'unknown';
1429
+ const upstreamPath = this.#rememberUpstreamPath(request);
1430
+ if (route.startsWith('/admin/'))
1431
+ return this.#admin(route, request, upstreamPath);
1432
+ if (route === '/wake' && request.method === 'GET')
1433
+ return this.#wake(request);
1434
+ if (route === '/notify' && request.method === 'POST') {
1435
+ try {
1436
+ const applied = await this.#ingest(upstreamPath);
1437
+ return json({ ok: true, applied });
1438
+ }
1439
+ catch (error) {
1440
+ return json(errorBody(error), statusOf(error));
1441
+ }
1442
+ }
1443
+ const claims = claimsFromRequest(request);
1444
+ if (!claims)
1445
+ return json({ error: 'missing normalized claims' }, 401);
1446
+ if (route === '/pull' && request.method === 'POST') {
1447
+ try {
1448
+ await this.#ingest(upstreamPath);
1449
+ }
1450
+ catch (error) {
1451
+ // Workerd requires a forwarded request body to be consumed even when
1452
+ // ingest fails before the pull handler parses it.
1453
+ await request.arrayBuffer();
1454
+ return json(errorBody(error), statusOf(error));
1455
+ }
1456
+ return this.#pull(request, claims, namespace);
1457
+ }
1458
+ if (route === '/push' && request.method === 'POST') {
1459
+ // A push may be the first request for a fresh namespace. DATA's
1460
+ // /changes call is also its schema-provisioning barrier, so complete it
1461
+ // before delegating the mutation to APP. Pull already enforces this
1462
+ // ordering above; skipping it here let APP race half-created tables and
1463
+ // surface a terminal, bodyless 500 to Zero.
1464
+ try {
1465
+ await this.#ingest(upstreamPath);
1466
+ }
1467
+ catch (error) {
1468
+ await request.arrayBuffer();
1469
+ return json(errorBody(error), statusOf(error));
1470
+ }
1471
+ return this.#push(request, claims, namespace, upstreamPath);
1472
+ }
1473
+ return json({ error: 'not found' }, 404);
1474
+ }
1475
+ async alarm() {
1476
+ if (this.ctx.getWebSockets().length === 0)
1477
+ return;
1478
+ try {
1479
+ await this.#ingest();
1480
+ }
1481
+ catch (error) {
1482
+ console.error(JSON.stringify({
1483
+ event: 'sync_upstream_ingest_error',
1484
+ status: statusOf(error),
1485
+ error: errorMessage(error),
1486
+ }));
1487
+ }
1488
+ finally {
1489
+ if (this.ctx.getWebSockets().length > 0) {
1490
+ const retryAfterMs = this.#ingestBreaker.status().retryAfterMs;
1491
+ await this.ctx.storage.setAlarm(Date.now() + Math.max(upstreamIntervalMs, retryAfterMs));
1492
+ }
1493
+ }
1494
+ }
1495
+ webSocketMessage(socket, message) {
1496
+ if (message === 'ping')
1497
+ socket.send('pong');
1498
+ }
1499
+ webSocketClose(socket, code, reason, _wasClean) {
1500
+ // The peer already closed, so echo the close to release the socket — but
1501
+ // WebSocket.close() rejects reserved/absent codes (1005 "no status", 1006
1502
+ // abnormal, 1015) with InvalidAccessError, and a real browser routinely
1503
+ // closes with 1001/1005. An uncaught throw here aborts the DO, so only
1504
+ // echo an application-permitted code and otherwise close cleanly.
1505
+ const echoable = code === 1000 || (code >= 3000 && code <= 4999);
1506
+ socketCloseQuietly(socket, echoable ? code : 1000, echoable ? reason : '');
1507
+ }
1508
+ webSocketError(socket, _error) {
1509
+ socketCloseQuietly(socket, 1011, 'wake socket error');
1510
+ }
1511
+ };
1512
+ }