orez-sync-cf-host 0.15.0 → 0.16.0

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.
package/dist/config.js CHANGED
@@ -66,6 +66,9 @@ export function validateSyncHostConfig(config) {
66
66
  ingestBudgetWindowMs: config.upstream.ingestBudgetWindowMs ?? 300_000,
67
67
  ingestBackoffMs: config.upstream.ingestBackoffMs ?? 1_000,
68
68
  ingestMaxBackoffMs: config.upstream.ingestMaxBackoffMs ?? 60_000,
69
+ requestTimeoutMs: config.upstream.requestTimeoutMs ?? 30_000,
70
+ maxResponseBytes: config.upstream.maxResponseBytes ?? 8 * 1024 * 1024,
71
+ maxRequestBytes: config.upstream.maxRequestBytes ?? 1024 * 1024,
69
72
  })) {
70
73
  if (!Number.isSafeInteger(value) || value < 1)
71
74
  throw new TypeError(`upstream.${name} must be a positive safe integer`);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sync-wasm",
3
3
  "type": "module",
4
- "version": "0.1.5",
4
+ "version": "0.1.6",
5
5
  "license": "MIT",
6
6
  "files": [
7
7
  "sync_wasm_bg.wasm",
@@ -507,7 +507,7 @@ function __wbg_get_imports() {
507
507
  const ret = Object.entries(arg0);
508
508
  return ret;
509
509
  },
510
- __wbg_exec_b39c16e60dbe8f94: function() { return handleError(function (arg0, arg1, arg2, arg3) {
510
+ __wbg_exec_47d28d9cda4f7ff5: function() { return handleError(function (arg0, arg1, arg2, arg3) {
511
511
  arg0.exec(getStringFromWasm0(arg1, arg2), arg3);
512
512
  }, arguments); },
513
513
  __wbg_get_507a50627bffa49b: function(arg0, arg1) {
@@ -617,7 +617,7 @@ function __wbg_get_imports() {
617
617
  __wbg_prototypesetcall_4770620bbe4688a0: function(arg0, arg1, arg2) {
618
618
  Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
619
619
  },
620
- __wbg_query_7af3043ddb76c6e2: function() { return handleError(function (arg0, arg1, arg2, arg3) {
620
+ __wbg_query_cf7ee856b3b99d70: function() { return handleError(function (arg0, arg1, arg2, arg3) {
621
621
  const ret = arg0.query(getStringFromWasm0(arg1, arg2), arg3);
622
622
  return ret;
623
623
  }, arguments); },
@@ -502,7 +502,7 @@ export function __wbg_entries_015dc610cd81ede0(arg0) {
502
502
  const ret = Object.entries(arg0);
503
503
  return ret;
504
504
  }
505
- export function __wbg_exec_b39c16e60dbe8f94() { return handleError(function (arg0, arg1, arg2, arg3) {
505
+ export function __wbg_exec_47d28d9cda4f7ff5() { return handleError(function (arg0, arg1, arg2, arg3) {
506
506
  arg0.exec(getStringFromWasm0(arg1, arg2), arg3);
507
507
  }, arguments); }
508
508
  export function __wbg_get_507a50627bffa49b(arg0, arg1) {
@@ -612,7 +612,7 @@ export function __wbg_next_71f2aa1cb3d1e37e() { return handleError(function (arg
612
612
  export function __wbg_prototypesetcall_4770620bbe4688a0(arg0, arg1, arg2) {
613
613
  Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
614
614
  }
615
- export function __wbg_query_7af3043ddb76c6e2() { return handleError(function (arg0, arg1, arg2, arg3) {
615
+ export function __wbg_query_cf7ee856b3b99d70() { return handleError(function (arg0, arg1, arg2, arg3) {
616
616
  const ret = arg0.query(getStringFromWasm0(arg1, arg2), arg3);
617
617
  return ret;
618
618
  }, arguments); }
Binary file
package/dist/host.d.ts CHANGED
@@ -9,8 +9,11 @@ import type { Schema } from '@rocicorp/zero';
9
9
  export declare function createSyncWorker<Env extends SyncHostEnv, S extends Schema = Schema>(config: SyncHostConfig<Env, S>): ExportedHandler<Env> & {
10
10
  notify(env: Env, namespace: string): Promise<Response>;
11
11
  };
12
+ export interface SyncDurableObjectInstance<Env extends SyncHostEnv> extends DurableObject<Env> {
13
+ fetch(request: Request): Promise<Response>;
14
+ }
12
15
  export interface SyncDurableObjectConstructor<Env extends SyncHostEnv> {
13
- new (ctx: DurableObjectState, env: Env): DurableObject<Env>;
16
+ new (ctx: DurableObjectState, env: Env): SyncDurableObjectInstance<Env>;
14
17
  }
15
18
  /** Create the namespace Durable Object class for one bundled consumer config. */
16
19
  export declare function createSyncDurableObject<Env extends SyncHostEnv, S extends Schema = Schema>(config: SyncHostConfig<Env, S>): SyncDurableObjectConstructor<Env>;
package/dist/host.js CHANGED
@@ -4,7 +4,9 @@ import { createSocketHost } from 'orez-sync-executor/realtime';
4
4
  import { validateSyncHostConfig } from './config.js';
5
5
  import { createQueryCompiler } from './query-compiler.js';
6
6
  import { resolveQueryPatch } from './query-patch.js';
7
+ import { ServingLagTracker } from './serving-lag.js';
7
8
  import { decodeSqlParams, SqlStorageDirect, SqlStorageMutatorTransaction, SqlStorageSyncDb, } from './sql-storage-adapter.js';
9
+ import { DEFAULT_UPSTREAM_MAX_RESPONSE_BYTES, DEFAULT_UPSTREAM_MAX_REQUEST_BYTES, DEFAULT_UPSTREAM_REQUEST_TIMEOUT_MS, fetchBoundedUpstreamJson, readBoundedJsonResponse, readBoundedStream, UpstreamResponseLimitError, } from './upstream-response.js';
8
10
  import { engine_authorize_realtime_subscription, engine_apply_snapshot_changes, engine_apply_snapshot_page, engine_apply_upstream, engine_begin_snapshot_generation, engine_finalize, engine_finalize_snapshot_generation, 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_schema_revision, engine_state, engine_version, } from './wasm.js';
9
11
  import { IngestBreakerError, IngestCircuitBreaker, retryDelayMs, shouldRetryDelegatedPush, } from './write-safeguards.js';
10
12
  const NAMESPACE_HEADER = 'x-orez-sync-namespace';
@@ -126,6 +128,24 @@ async function requestObject(request) {
126
128
  throw requestError('request body must be a JSON object');
127
129
  return value;
128
130
  }
131
+ async function boundedRequestObject(request, maxBytes, timeoutMs) {
132
+ try {
133
+ const bytes = await readBoundedStream(request.body, maxBytes, AbortSignal.timeout(timeoutMs));
134
+ const value = JSON.parse(new TextDecoder().decode(bytes));
135
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
136
+ throw requestError('request body must be a JSON object');
137
+ }
138
+ return value;
139
+ }
140
+ catch (error) {
141
+ if (error instanceof UpstreamResponseLimitError) {
142
+ throw requestError(`request body exceeded ${maxBytes} bytes`, 413);
143
+ }
144
+ if (error instanceof SyntaxError)
145
+ throw requestError('invalid JSON request body');
146
+ throw error;
147
+ }
148
+ }
129
149
  function routeAfterNamespace(pathname) {
130
150
  const [, , ...parts] = pathname.split('/');
131
151
  return `/${parts.join('/')}`;
@@ -160,6 +180,7 @@ async function forwardedSyncRequest(request) {
160
180
  const headers = new Headers(request.headers);
161
181
  return {
162
182
  claims: claims,
183
+ body: body,
163
184
  request: jsonBodyRequest(request, headers, body),
164
185
  };
165
186
  }
@@ -225,6 +246,8 @@ function socketCloseQuietly(socket, code, reason) {
225
246
  */
226
247
  export function createSyncWorker(config) {
227
248
  validateSyncHostConfig(config);
249
+ const requestTimeoutMs = config.upstream?.requestTimeoutMs ?? DEFAULT_UPSTREAM_REQUEST_TIMEOUT_MS;
250
+ const maxRequestBytes = config.upstream?.maxRequestBytes ?? DEFAULT_UPSTREAM_MAX_REQUEST_BYTES;
228
251
  const allowedOrigins = new Set(config.allowedOrigins ?? []);
229
252
  const upstreamPath = (namespace) => {
230
253
  if (!config.upstream)
@@ -374,7 +397,7 @@ export function createSyncWorker(config) {
374
397
  }
375
398
  if ((route === '/pull' || route === '/push') && request.method === 'POST') {
376
399
  try {
377
- const body = await requestObject(request);
400
+ const body = await boundedRequestObject(request, maxRequestBytes, requestTimeoutMs);
378
401
  forwardedBody = { claims, body };
379
402
  }
380
403
  catch (error) {
@@ -443,6 +466,9 @@ export function createSyncDurableObject(config) {
443
466
  const wakeCoalesceMs = config.wakeCoalesceMs ?? 25;
444
467
  const upstreamIntervalMs = config.upstream?.intervalMs ?? 15_000;
445
468
  const upstreamLimit = config.upstream?.changeLimit ?? 1_000;
469
+ const upstreamRequestTimeoutMs = config.upstream?.requestTimeoutMs ?? DEFAULT_UPSTREAM_REQUEST_TIMEOUT_MS;
470
+ const upstreamMaxResponseBytes = config.upstream?.maxResponseBytes ?? DEFAULT_UPSTREAM_MAX_RESPONSE_BYTES;
471
+ const upstreamMaxRequestBytes = config.upstream?.maxRequestBytes ?? DEFAULT_UPSTREAM_MAX_REQUEST_BYTES;
446
472
  const ingestBudgetRows = config.upstream?.ingestBudgetRows ?? 150_000;
447
473
  const ingestBudgetWindowMs = config.upstream?.ingestBudgetWindowMs ?? 5 * 60_000;
448
474
  const ingestBackoffMs = config.upstream?.ingestBackoffMs ?? 1_000;
@@ -471,6 +497,8 @@ export function createSyncDurableObject(config) {
471
497
  #counters = freshCounters();
472
498
  #sqlBilling = { rowsRead: 0, rowsWritten: 0 };
473
499
  #pulling = new Set();
500
+ #activePullGroups = new Map();
501
+ #servingLag = new ServingLagTracker();
474
502
  #wakeOrigins = new Set();
475
503
  #wakeRecipients = new Set();
476
504
  #wakeTables = new Set();
@@ -558,6 +586,15 @@ export function createSyncDurableObject(config) {
558
586
  schema: config.schema,
559
587
  })
560
588
  : null;
589
+ // Upstream-backed namespaces are initialized lazily so a notify for a
590
+ // namespace with no wake subscribers remains a true zero-read/write
591
+ // fast path. Local-execution namespaces have no such notify path, and
592
+ // preserving eager initialization there matters for consumers that
593
+ // subclass this Durable Object and handle an application route before
594
+ // delegating to super.fetch().
595
+ if (!config.upstream) {
596
+ ctx.blockConcurrencyWhile(async () => this.#initialize());
597
+ }
561
598
  }
562
599
  #initialize() {
563
600
  if (this.#initialized)
@@ -703,6 +740,41 @@ export function createSyncDurableObject(config) {
703
740
  }
704
741
  return value;
705
742
  }
743
+ #activeClientGroupIDs() {
744
+ const groups = new Set(this.#activePullGroups.keys());
745
+ for (const socket of this.ctx.getWebSockets(WAKE_SUBSCRIBER_TAG)) {
746
+ const clientGroupID = socketAttachment(socket)?.identity?.clientGroupID;
747
+ if (clientGroupID)
748
+ groups.add(clientGroupID);
749
+ }
750
+ return groups;
751
+ }
752
+ #beginActivePull(clientGroupID) {
753
+ this.#activePullGroups.set(clientGroupID, (this.#activePullGroups.get(clientGroupID) ?? 0) + 1);
754
+ }
755
+ #endActivePull(clientGroupID) {
756
+ const count = this.#activePullGroups.get(clientGroupID) ?? 0;
757
+ if (count <= 1)
758
+ this.#activePullGroups.delete(clientGroupID);
759
+ else
760
+ this.#activePullGroups.set(clientGroupID, count - 1);
761
+ }
762
+ async #fetchUpstreamJson(endpoint) {
763
+ const result = await fetchBoundedUpstreamJson(this.#serviceBinding(), endpoint.toString(), { headers: { host: endpoint.host } }, {
764
+ timeoutMs: upstreamRequestTimeoutMs,
765
+ maxBytes: upstreamMaxResponseBytes,
766
+ });
767
+ const sourceTimeMs = result.body &&
768
+ typeof result.body === 'object' &&
769
+ !Array.isArray(result.body) &&
770
+ typeof result.body.sourceTimeMs === 'number'
771
+ ? result.body.sourceTimeMs
772
+ : null;
773
+ if (sourceTimeMs !== null) {
774
+ this.#servingLag.recordClockSkew(sourceTimeMs, result.sendTimeMs, result.receiveTimeMs);
775
+ }
776
+ return result;
777
+ }
706
778
  async #upstreamWriteBudgetStatus() {
707
779
  if (!config.upstream)
708
780
  return json({ error: 'upstream is not configured' }, 404);
@@ -710,16 +782,14 @@ export function createSyncDurableObject(config) {
710
782
  if (path === null)
711
783
  return json({ error: 'upstream path is not known yet' }, 409);
712
784
  const endpoint = new URL(`${path}/_orez/write-budget`, 'https://upstream.invalid');
713
- const response = await this.#serviceBinding().fetch(endpoint.toString(), {
714
- headers: { host: endpoint.host },
715
- });
785
+ const { response, body } = await this.#fetchUpstreamJson(endpoint);
716
786
  if (!response.ok) {
717
787
  return json({
718
788
  error: 'upstream write-budget status unavailable',
719
789
  upstreamStatus: response.status,
720
790
  }, 502);
721
791
  }
722
- return json(await response.json());
792
+ return json(body);
723
793
  }
724
794
  #rememberUpstreamPath(request) {
725
795
  if (!config.upstream)
@@ -810,13 +880,11 @@ export function createSyncDurableObject(config) {
810
880
  endpoint.searchParams.set('limit', String(limit));
811
881
  if (cursor !== null)
812
882
  endpoint.searchParams.set('cursor', cursor);
813
- const response = await this.#serviceBinding().fetch(endpoint.toString(), {
814
- headers: { host: endpoint.host },
815
- });
883
+ const { response, body } = await this.#fetchUpstreamJson(endpoint);
816
884
  if (!response.ok) {
817
885
  throw requestError(`upstream snapshot page returned ${response.status}`, response.status >= 500 ? 502 : response.status);
818
886
  }
819
- const page = (await response.json());
887
+ const page = body;
820
888
  if (!Number.isSafeInteger(page.watermark) ||
821
889
  Number(page.watermark) < 0 ||
822
890
  !Array.isArray(page.rows) ||
@@ -877,6 +945,7 @@ export function createSyncDurableObject(config) {
877
945
  let pendingPage = null;
878
946
  let snapshotPageLimit = DEFAULT_SNAPSHOT_PAGE_ROWS;
879
947
  let snapshotCompleted = false;
948
+ let oldestLiveCommitTimeMs;
880
949
  const changedTables = new Set();
881
950
  for (;;) {
882
951
  if (progress?.state === 'paging') {
@@ -921,9 +990,7 @@ export function createSyncDurableObject(config) {
921
990
  const endpoint = new URL(`${path}/changes`, 'https://upstream.invalid');
922
991
  endpoint.searchParams.set('since', cursor);
923
992
  endpoint.searchParams.set('limit', String(upstreamLimit));
924
- const response = await this.#serviceBinding().fetch(endpoint.toString(), {
925
- headers: { host: endpoint.host },
926
- });
993
+ const { response, body } = await this.#fetchUpstreamJson(endpoint);
927
994
  if (response.status === 410) {
928
995
  const begun = await this.#beginSnapshotGeneration(path);
929
996
  progress = begun.progress;
@@ -934,7 +1001,7 @@ export function createSyncDurableObject(config) {
934
1001
  if (!response.ok) {
935
1002
  throw new Error(`upstream snapshot catch-up returned ${response.status}`);
936
1003
  }
937
- const batch = (await response.json());
1004
+ const batch = body;
938
1005
  if (!Number.isSafeInteger(batch.watermark) || !Array.isArray(batch.changes)) {
939
1006
  throw new Error('invalid upstream changes response');
940
1007
  }
@@ -989,9 +1056,7 @@ export function createSyncDurableObject(config) {
989
1056
  const endpoint = new URL(`${path}/changes`, 'https://upstream.invalid');
990
1057
  endpoint.searchParams.set('watermark', cursor);
991
1058
  endpoint.searchParams.set('limit', String(upstreamLimit));
992
- const response = await this.#serviceBinding().fetch(endpoint.toString(), {
993
- headers: { host: endpoint.host },
994
- });
1059
+ const { response, body } = await this.#fetchUpstreamJson(endpoint);
995
1060
  if (response.status === 410) {
996
1061
  const begun = await this.#beginSnapshotGeneration(path);
997
1062
  progress = begun.progress;
@@ -1002,10 +1067,18 @@ export function createSyncDurableObject(config) {
1002
1067
  if (!response.ok) {
1003
1068
  throw new Error(`upstream changes returned ${response.status}`);
1004
1069
  }
1005
- const batch = (await response.json());
1070
+ const batch = body;
1006
1071
  if (!Number.isSafeInteger(batch.watermark) || !Array.isArray(batch.changes)) {
1007
1072
  throw new Error('invalid upstream changes response');
1008
1073
  }
1074
+ const batchCommitTimeMs = batch.oldestCommitTimeMs;
1075
+ if (typeof batchCommitTimeMs === 'number' &&
1076
+ Number.isFinite(batchCommitTimeMs)) {
1077
+ oldestLiveCommitTimeMs =
1078
+ oldestLiveCommitTimeMs === undefined
1079
+ ? batchCommitTimeMs
1080
+ : Math.min(oldestLiveCommitTimeMs, batchCommitTimeMs);
1081
+ }
1009
1082
  for (const table of upstreamBatchTables(batch))
1010
1083
  changedTables.add(table);
1011
1084
  const result = this.#withIngestBilling({
@@ -1044,6 +1117,18 @@ export function createSyncDurableObject(config) {
1044
1117
  }
1045
1118
  this.#recoverIngestBreaker();
1046
1119
  const endingWatermark = this.#engineState().watermark;
1120
+ if (oldestLiveCommitTimeMs !== undefined) {
1121
+ const activeGroups = this.#activeClientGroupIDs();
1122
+ if (endingWatermark !== startingWatermark || total > 0) {
1123
+ this.#servingLag.onVersionReady(endingWatermark, oldestLiveCommitTimeMs, activeGroups);
1124
+ }
1125
+ else {
1126
+ const servedAt = Date.now();
1127
+ for (const _clientGroupID of activeGroups) {
1128
+ this.#servingLag.recordNoChange(oldestLiveCommitTimeMs, servedAt);
1129
+ }
1130
+ }
1131
+ }
1047
1132
  if (snapshotCompleted || total > 0 || endingWatermark !== startingWatermark) {
1048
1133
  await this.#enqueueWake('__upstream__', changedTables);
1049
1134
  }
@@ -1259,7 +1344,10 @@ export function createSyncDurableObject(config) {
1259
1344
  wasmBoundaryCalls: this.#counters.wasmBoundaryCalls,
1260
1345
  sql: this.#engineDb.stats,
1261
1346
  });
1262
- return json(response);
1347
+ const result = json(response);
1348
+ const clientGroupID = typeof body.clientGroupID === 'string' ? body.clientGroupID : '';
1349
+ this.#servingLag.onVersionServed(clientGroupID, response.cookie);
1350
+ return result;
1263
1351
  }
1264
1352
  catch (error) {
1265
1353
  const status = statusOf(error);
@@ -1299,7 +1387,7 @@ export function createSyncDurableObject(config) {
1299
1387
  if (!this.#writerEnabled()) {
1300
1388
  // Workerd requires the request stream to be consumed before the DO
1301
1389
  // returns a response. Discard it without parsing or logging payloads.
1302
- await request.arrayBuffer();
1390
+ await readBoundedStream(request.body, upstreamMaxRequestBytes, AbortSignal.timeout(upstreamRequestTimeoutMs));
1303
1391
  const state = this.#engineStateBestEffort();
1304
1392
  this.#log({
1305
1393
  namespaceHash: namespace,
@@ -1325,7 +1413,8 @@ export function createSyncDurableObject(config) {
1325
1413
  }
1326
1414
  if (config.mutateUrl) {
1327
1415
  try {
1328
- const bytes = await request.arrayBuffer();
1416
+ const boundedBytes = await readBoundedStream(request.body, upstreamMaxRequestBytes, AbortSignal.timeout(upstreamRequestTimeoutMs));
1417
+ const bytes = boundedBytes.buffer.slice(boundedBytes.byteOffset, boundedBytes.byteOffset + boundedBytes.byteLength);
1329
1418
  const body = JSON.parse(new TextDecoder().decode(bytes));
1330
1419
  const plan = this.#wasm(() => engine_push_validate(body));
1331
1420
  if (plan.kind === 'respond')
@@ -1337,9 +1426,11 @@ export function createSyncDurableObject(config) {
1337
1426
  headers.set('host', endpoint.host);
1338
1427
  const upstreamResponse = await this.#fetchDelegatedPush(endpoint, headers, bytes, this.#engineState().upstreamWatermark === '0');
1339
1428
  if (!upstreamResponse.ok) {
1340
- return new Response(upstreamResponse.body, upstreamResponse);
1429
+ const upstreamErrorBody = await readBoundedStream(upstreamResponse.body, upstreamMaxResponseBytes, AbortSignal.timeout(upstreamRequestTimeoutMs));
1430
+ const errorBytes = upstreamErrorBody.buffer.slice(upstreamErrorBody.byteOffset, upstreamErrorBody.byteOffset + upstreamErrorBody.byteLength);
1431
+ return new Response(errorBytes, upstreamResponse);
1341
1432
  }
1342
- const upstreamBody = (await upstreamResponse.json());
1433
+ const upstreamBody = (await readBoundedJsonResponse(upstreamResponse, upstreamMaxResponseBytes, AbortSignal.timeout(upstreamRequestTimeoutMs)));
1343
1434
  const delegatedResponse = upstreamBody.pushResponse ?? upstreamBody;
1344
1435
  if (isStructuredPushFailed(delegatedResponse)) {
1345
1436
  // PushFailed is a successful protocol response describing an
@@ -1803,17 +1894,28 @@ export function createSyncDurableObject(config) {
1803
1894
  catch (error) {
1804
1895
  return errorResponse(error);
1805
1896
  }
1806
- // pull and push both establish the upstream schema and snapshot barrier.
1897
+ const clientGroupID = route === '/pull' && typeof forwarded.body.clientGroupID === 'string'
1898
+ ? forwarded.body.clientGroupID
1899
+ : null;
1900
+ if (clientGroupID)
1901
+ this.#beginActivePull(clientGroupID);
1807
1902
  try {
1808
- await this.#ingest(upstreamPath);
1809
- }
1810
- catch (error) {
1811
- return errorResponse(error);
1903
+ // pull and push both establish the upstream schema and snapshot barrier.
1904
+ try {
1905
+ await this.#ingest(upstreamPath);
1906
+ }
1907
+ catch (error) {
1908
+ return errorResponse(error);
1909
+ }
1910
+ if (route === '/pull') {
1911
+ return await this.#pull(forwarded.request, forwarded.claims, namespace);
1912
+ }
1913
+ return await this.#push(forwarded.request, forwarded.claims, namespace, upstreamPath);
1812
1914
  }
1813
- if (route === '/pull') {
1814
- return this.#pull(forwarded.request, forwarded.claims, namespace);
1915
+ finally {
1916
+ if (clientGroupID)
1917
+ this.#endActivePull(clientGroupID);
1815
1918
  }
1816
- return this.#push(forwarded.request, forwarded.claims, namespace, upstreamPath);
1817
1919
  }
1818
1920
  return json({ error: 'not found' }, 404);
1819
1921
  }
package/dist/index.d.ts CHANGED
@@ -3,6 +3,6 @@ export { createQueryCompiler } from './query-compiler.js';
3
3
  export { resolveQueryPatch } from './query-patch.js';
4
4
  export { DEFAULT_TRANSACTION_QUERY_BUDGET, TransactionQueryBudgetError, executeTransactionQueryPlan, executeTransactionQueryPlanAsync, } from './transaction-query.js';
5
5
  export type { SyncHostConfig, SyncHostEnv, ServiceBinding, SyncSql, UpstreamConfig, } from './types.js';
6
- export type { SyncDurableObjectConstructor } from './host.js';
6
+ export type { SyncDurableObjectConstructor, SyncDurableObjectInstance } from './host.js';
7
7
  export type { CompiledTransactionQueryNode, CompiledTransactionQueryPlan, CompiledTransactionQueryRelationship, TransactionQueryBinding, TransactionQueryBudget, TransactionQueryColumn, TransactionQueryColumnType, TransactionQueryExecutionOptions, TransactionQueryWireValue, } from './transaction-query.js';
8
8
  export type { TransactionQueryCompiler } from './query-compiler.js';
@@ -0,0 +1,27 @@
1
+ type MetricAttributes = Record<string, string | number | boolean>;
2
+ type Histogram = {
3
+ record(value: number, attributes?: MetricAttributes): void;
4
+ };
5
+ type Counter = {
6
+ add(value: number, attributes?: MetricAttributes): void;
7
+ };
8
+ export type ServingLagInstruments = {
9
+ servingLag: Histogram;
10
+ servingLagClamps: Counter;
11
+ upstreamClockSkew: Histogram;
12
+ };
13
+ /**
14
+ * Coalesces upstream commits independently for each active client group. A
15
+ * pull clears a pending observation even when its patch is empty: being
16
+ * current through the version is the completion event, not receiving a row.
17
+ */
18
+ export declare class ServingLagTracker {
19
+ #private;
20
+ private readonly metrics;
21
+ constructor(metrics?: ServingLagInstruments);
22
+ onVersionReady(watermarkValue: unknown, upstreamCommitTimeMs: number, clientGroupIDs: Iterable<string>): void;
23
+ onVersionServed(clientGroupID: string, watermarkValue: unknown, nowMs?: number): void;
24
+ recordNoChange(upstreamCommitTimeMs: number, nowMs?: number): void;
25
+ recordClockSkew(sourceTimeMs: number, sendTimeMs: number, receiveTimeMs: number): void;
26
+ }
27
+ export {};
@@ -0,0 +1,83 @@
1
+ import { metrics } from '@opentelemetry/api';
2
+ let defaultInstruments;
3
+ function instruments() {
4
+ if (defaultInstruments)
5
+ return defaultInstruments;
6
+ const meter = metrics.getMeter('orez');
7
+ defaultInstruments = {
8
+ servingLag: meter.createHistogram('orez.sync.e2e_serving_lag', {
9
+ description: 'End-to-end completion lag from an upstream commit until a client group is served through that advancement.',
10
+ unit: 's',
11
+ }),
12
+ servingLagClamps: meter.createCounter('orez.sync.e2e_serving_lag_clamps', {
13
+ description: 'Negative end-to-end serving-lag observations clamped to zero because the upstream clock was ahead.',
14
+ unit: '{observation}',
15
+ }),
16
+ upstreamClockSkew: meter.createHistogram('orez.replication.upstream_clock_skew', {
17
+ description: 'Estimated upstream clock offset from the Orez host. Positive means upstream is ahead.',
18
+ unit: 's',
19
+ }),
20
+ };
21
+ return defaultInstruments;
22
+ }
23
+ function counter(value) {
24
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) {
25
+ return BigInt(value);
26
+ }
27
+ if (typeof value === 'string' && /^(?:0|[1-9]\d*)$/.test(value)) {
28
+ return BigInt(value);
29
+ }
30
+ return null;
31
+ }
32
+ /**
33
+ * Coalesces upstream commits independently for each active client group. A
34
+ * pull clears a pending observation even when its patch is empty: being
35
+ * current through the version is the completion event, not receiving a row.
36
+ */
37
+ export class ServingLagTracker {
38
+ metrics;
39
+ #pending = new Map();
40
+ constructor(metrics = instruments()) {
41
+ this.metrics = metrics;
42
+ }
43
+ onVersionReady(watermarkValue, upstreamCommitTimeMs, clientGroupIDs) {
44
+ const watermark = counter(watermarkValue);
45
+ if (watermark === null || !Number.isFinite(upstreamCommitTimeMs))
46
+ return;
47
+ for (const clientGroupID of clientGroupIDs) {
48
+ if (!clientGroupID)
49
+ continue;
50
+ const previous = this.#pending.get(clientGroupID);
51
+ this.#pending.set(clientGroupID, {
52
+ watermark,
53
+ commitTimeMs: previous
54
+ ? Math.min(previous.commitTimeMs, upstreamCommitTimeMs)
55
+ : upstreamCommitTimeMs,
56
+ });
57
+ }
58
+ }
59
+ onVersionServed(clientGroupID, watermarkValue, nowMs = Date.now()) {
60
+ const served = counter(watermarkValue);
61
+ const pending = this.#pending.get(clientGroupID);
62
+ if (served === null || !pending || served < pending.watermark)
63
+ return;
64
+ this.#pending.delete(clientGroupID);
65
+ this.#record(nowMs - pending.commitTimeMs, { outcome: 'advanced' });
66
+ }
67
+ recordNoChange(upstreamCommitTimeMs, nowMs = Date.now()) {
68
+ if (!Number.isFinite(upstreamCommitTimeMs))
69
+ return;
70
+ this.#record(nowMs - upstreamCommitTimeMs, { outcome: 'no_change' });
71
+ }
72
+ recordClockSkew(sourceTimeMs, sendTimeMs, receiveTimeMs) {
73
+ if (![sourceTimeMs, sendTimeMs, receiveTimeMs].every(Number.isFinite))
74
+ return;
75
+ this.metrics.upstreamClockSkew.record((sourceTimeMs - (sendTimeMs + receiveTimeMs) / 2) / 1_000);
76
+ }
77
+ #record(rawLagMs, attributes) {
78
+ const clamped = rawLagMs < 0;
79
+ this.metrics.servingLag.record(Math.max(0, rawLagMs) / 1_000, attributes);
80
+ if (clamped)
81
+ this.metrics.servingLagClamps.add(1, attributes);
82
+ }
83
+ }
package/dist/types.d.ts CHANGED
@@ -20,6 +20,12 @@ export type UpstreamConfig = {
20
20
  namespacePath: string | ((namespace: string) => string);
21
21
  /** Feed page size; the cursor loop continues until the reported head is reached. */
22
22
  changeLimit?: number;
23
+ /** Whole-request deadline, including the successful response body. Defaults to 30s. */
24
+ requestTimeoutMs?: number;
25
+ /** Maximum decoded JSON response size. Defaults to 8 MiB. */
26
+ maxResponseBytes?: number;
27
+ /** Maximum forwarded pull or push request size. Defaults to 1 MiB. */
28
+ maxRequestBytes?: number;
23
29
  /** Active wake-socket alarm safety net. Defaults to 15 seconds. */
24
30
  intervalMs?: number;
25
31
  /** Billable SQLite rows written by ingest per rolling window. Defaults to 150,000. */
@@ -0,0 +1,22 @@
1
+ export declare const DEFAULT_UPSTREAM_REQUEST_TIMEOUT_MS = 30000;
2
+ export declare const DEFAULT_UPSTREAM_MAX_RESPONSE_BYTES: number;
3
+ export declare const DEFAULT_UPSTREAM_MAX_REQUEST_BYTES: number;
4
+ export declare class UpstreamResponseLimitError extends Error {
5
+ readonly maxBytes: number;
6
+ readonly status = 502;
7
+ constructor(maxBytes: number);
8
+ }
9
+ export declare function readBoundedJsonResponse(response: Response, maxBytes: number, signal: AbortSignal): Promise<unknown>;
10
+ export declare function readBoundedStream(stream: ReadableStream<Uint8Array> | null, maxBytes: number, signal: AbortSignal): Promise<Uint8Array>;
11
+ export declare function fetchBoundedUpstreamJson(binding: {
12
+ fetch(input: string | Request, init?: RequestInit): Promise<Response>;
13
+ }, input: string, init: RequestInit, options: {
14
+ timeoutMs: number;
15
+ maxBytes: number;
16
+ now?: () => number;
17
+ }): Promise<{
18
+ response: Response;
19
+ body: unknown | null;
20
+ sendTimeMs: number;
21
+ receiveTimeMs: number;
22
+ }>;
@@ -0,0 +1,101 @@
1
+ export const DEFAULT_UPSTREAM_REQUEST_TIMEOUT_MS = 30_000;
2
+ export const DEFAULT_UPSTREAM_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
3
+ export const DEFAULT_UPSTREAM_MAX_REQUEST_BYTES = 1024 * 1024;
4
+ export class UpstreamResponseLimitError extends Error {
5
+ maxBytes;
6
+ status = 502;
7
+ constructor(maxBytes) {
8
+ super(`upstream response exceeded ${maxBytes} bytes`);
9
+ this.maxBytes = maxBytes;
10
+ this.name = 'UpstreamResponseLimitError';
11
+ }
12
+ }
13
+ function abortError(signal) {
14
+ return signal.reason instanceof Error
15
+ ? signal.reason
16
+ : new Error('upstream response was aborted');
17
+ }
18
+ async function readWithAbort(read, signal, cancel) {
19
+ if (signal.aborted)
20
+ throw abortError(signal);
21
+ let remove = () => { };
22
+ const aborted = new Promise((_, reject) => {
23
+ const onAbort = () => {
24
+ void cancel().catch(() => { });
25
+ reject(abortError(signal));
26
+ };
27
+ signal.addEventListener('abort', onAbort, { once: true });
28
+ remove = () => signal.removeEventListener('abort', onAbort);
29
+ });
30
+ try {
31
+ return await Promise.race([read, aborted]);
32
+ }
33
+ finally {
34
+ remove();
35
+ }
36
+ }
37
+ export async function readBoundedJsonResponse(response, maxBytes, signal) {
38
+ const contentLength = Number(response.headers.get('content-length'));
39
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
40
+ await response.body?.cancel().catch(() => { });
41
+ throw new UpstreamResponseLimitError(maxBytes);
42
+ }
43
+ if (!response.body)
44
+ throw new Error('upstream response has no body');
45
+ const body = await readBoundedStream(response.body, maxBytes, signal);
46
+ return JSON.parse(new TextDecoder().decode(body));
47
+ }
48
+ export async function readBoundedStream(stream, maxBytes, signal) {
49
+ if (!stream)
50
+ return new Uint8Array();
51
+ const reader = stream.getReader();
52
+ const chunks = [];
53
+ let bytes = 0;
54
+ for (;;) {
55
+ if (signal.aborted) {
56
+ await reader.cancel().catch(() => { });
57
+ throw abortError(signal);
58
+ }
59
+ const result = await readWithAbort(reader.read(), signal, () => reader.cancel());
60
+ if (result.done)
61
+ break;
62
+ bytes += result.value.byteLength;
63
+ if (bytes > maxBytes) {
64
+ await reader.cancel().catch(() => { });
65
+ throw new UpstreamResponseLimitError(maxBytes);
66
+ }
67
+ chunks.push(result.value);
68
+ }
69
+ const body = new Uint8Array(bytes);
70
+ let offset = 0;
71
+ for (const chunk of chunks) {
72
+ body.set(chunk, offset);
73
+ offset += chunk.byteLength;
74
+ }
75
+ return body;
76
+ }
77
+ export async function fetchBoundedUpstreamJson(binding, input, init, options) {
78
+ const now = options.now ?? Date.now;
79
+ const controller = new AbortController();
80
+ const timer = setTimeout(() => {
81
+ controller.abort(new Error(`upstream response missed ${options.timeoutMs}ms deadline`));
82
+ }, options.timeoutMs);
83
+ const sendTimeMs = now();
84
+ try {
85
+ const response = await binding.fetch(input, { ...init, signal: controller.signal });
86
+ if (!response.ok) {
87
+ await response.body?.cancel().catch(() => { });
88
+ return { response, body: null, sendTimeMs, receiveTimeMs: now() };
89
+ }
90
+ const body = await readBoundedJsonResponse(response, options.maxBytes, controller.signal);
91
+ return { response, body, sendTimeMs, receiveTimeMs: now() };
92
+ }
93
+ catch (error) {
94
+ if (controller.signal.aborted)
95
+ throw abortError(controller.signal);
96
+ throw error;
97
+ }
98
+ finally {
99
+ clearTimeout(timer);
100
+ }
101
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orez-sync-cf-host",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Internal orez Cloudflare sync host. Do not import directly: consumers use orez-lite/cloudflare/sync.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -34,7 +34,8 @@
34
34
  },
35
35
  "./wasm-module.wasm": {
36
36
  "types": "./dist/generated/sync_wasm_bg.wasm.d.ts",
37
- "import": "./dist/generated/sync_wasm_bg.wasm"
37
+ "import": "./dist/generated/sync_wasm_bg.wasm",
38
+ "default": "./dist/generated/sync_wasm_bg.wasm"
38
39
  }
39
40
  },
40
41
  "scripts": {
@@ -47,7 +48,7 @@
47
48
  "bundle": "bun run build:dist && wrangler deploy --dry-run --outdir .wrangler/bundle",
48
49
  "measure": "bun run build:dist && bun measure.mjs",
49
50
  "test": "bun run test:config && bun run test:query-compiler && bun run test:package && bun run test:platform && bun run test:integration && bun run test:large-data && bun run test:ingest && bun run test:restart",
50
- "test:config": "bun test config.test.mjs write-safeguards.test.mjs transaction-query.test.mjs",
51
+ "test:config": "bun test config.test.mjs write-safeguards.test.mjs transaction-query.test.mjs upstream-response.test.mjs serving-lag.test.mjs",
51
52
  "test:integration": "bun run build:dist && bun integration-test.mjs",
52
53
  "test:ingest": "bun run build:dist && bun ingest-test.mjs",
53
54
  "test:package": "bun run build:dist && node package-runtime-test.mjs",
@@ -58,7 +59,8 @@
58
59
  "test:large-data": "bun run build:dist && bun large-data-test.mjs"
59
60
  },
60
61
  "dependencies": {
61
- "orez-sync-executor": "0.15.0"
62
+ "@opentelemetry/api": "^1.9.0",
63
+ "orez-sync-executor": "0.16.0"
62
64
  },
63
65
  "devDependencies": {
64
66
  "@cloudflare/workers-types": "4.20260617.1",