orez-sync-cf-host 0.12.1 → 0.12.3

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.
@@ -105,6 +105,12 @@ export function engine_read_snapshot_progress(db: any): any;
105
105
 
106
106
  export function engine_record_app_error(db: any, client_group_id: string, client_id: string, mutation_id: string, user_id: string): void;
107
107
 
108
+ /**
109
+ * Identity of the durable DDL this engine build installs. Hosts fold it into
110
+ * the fingerprint that lets an up-to-date namespace skip the schema pass.
111
+ */
112
+ export function engine_schema_revision(): string;
113
+
108
114
  export function engine_state(db: any): any;
109
115
 
110
116
  export function engine_version(): string;
@@ -133,6 +139,7 @@ export interface InitOutput {
133
139
  readonly engine_push_validate: (a: any) => [number, number, number];
134
140
  readonly engine_read_snapshot_progress: (a: any) => [number, number, number];
135
141
  readonly engine_record_app_error: (a: any, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => [number, number];
142
+ readonly engine_schema_revision: () => [number, number];
136
143
  readonly engine_state: (a: any) => [number, number, number];
137
144
  readonly engine_version: () => [number, number];
138
145
  readonly __wbindgen_malloc: (a: number, b: number) => number;
@@ -352,6 +352,24 @@ export function engine_record_app_error(db, client_group_id, client_id, mutation
352
352
  }
353
353
  }
354
354
 
355
+ /**
356
+ * Identity of the durable DDL this engine build installs. Hosts fold it into
357
+ * the fingerprint that lets an up-to-date namespace skip the schema pass.
358
+ * @returns {string}
359
+ */
360
+ export function engine_schema_revision() {
361
+ let deferred1_0;
362
+ let deferred1_1;
363
+ try {
364
+ const ret = wasm.engine_schema_revision();
365
+ deferred1_0 = ret[0];
366
+ deferred1_1 = ret[1];
367
+ return getStringFromWasm0(ret[0], ret[1]);
368
+ } finally {
369
+ wasm.__wbindgen_free(deferred1_0, deferred1_1, 1);
370
+ }
371
+ }
372
+
355
373
  /**
356
374
  * @param {any} db
357
375
  * @returns {any}
@@ -350,6 +350,24 @@ export function engine_record_app_error(db, client_group_id, client_id, mutation
350
350
  }
351
351
  }
352
352
 
353
+ /**
354
+ * Identity of the durable DDL this engine build installs. Hosts fold it into
355
+ * the fingerprint that lets an up-to-date namespace skip the schema pass.
356
+ * @returns {string}
357
+ */
358
+ export function engine_schema_revision() {
359
+ let deferred1_0;
360
+ let deferred1_1;
361
+ try {
362
+ const ret = wasm.engine_schema_revision();
363
+ deferred1_0 = ret[0];
364
+ deferred1_1 = ret[1];
365
+ return getStringFromWasm0(ret[0], ret[1]);
366
+ } finally {
367
+ wasm.__wbindgen_free(deferred1_0, deferred1_1, 1);
368
+ }
369
+ }
370
+
353
371
  /**
354
372
  * @param {any} db
355
373
  * @returns {any}
Binary file
package/dist/host.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { DurableObject } from 'cloudflare:workers';
2
- import { createSyncExecutor } from 'orez-sync-executor/core';
2
+ import { createSyncExecutor, isMutationRetryError } from 'orez-sync-executor/core';
3
3
  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
7
  import { decodeSqlParams, SqlStorageDirect, SqlStorageMutatorTransaction, SqlStorageSyncDb, } from './sql-storage-adapter.js';
8
- 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_state, engine_version, } from './wasm.js';
8
+ 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
9
  import { IngestBreakerError, IngestCircuitBreaker, retryDelayMs, shouldRetryDelegatedPush, } from './write-safeguards.js';
10
10
  const NAMESPACE_HEADER = 'x-orez-sync-namespace';
11
11
  const UPSTREAM_PATH_HEADER = 'x-orez-sync-upstream-path';
@@ -31,10 +31,10 @@ function freshCounters() {
31
31
  externalEffectFailures: 0,
32
32
  };
33
33
  }
34
- function json(value, status = 200) {
34
+ function json(value, status = 200, headers) {
35
35
  return Response.json(value, {
36
36
  status,
37
- headers: { 'cache-control': 'no-store' },
37
+ headers: { 'cache-control': 'no-store', ...headers },
38
38
  });
39
39
  }
40
40
  function isStructuredPushFailed(value) {
@@ -67,8 +67,29 @@ function errorBody(error) {
67
67
  retryAfterMs: error.retryAfterMs,
68
68
  };
69
69
  }
70
+ if (isMutationRetryError(error)) {
71
+ // the reason travels in `details` so a caller can pace on why it was
72
+ // refused. it used to arrive as an acknowledged app error, which cost a
73
+ // ledger write per refusal and consumed the mutation.
74
+ return {
75
+ error: errorMessage(error),
76
+ ...(error.details === undefined ? {} : { details: error.details }),
77
+ retryAfterMs: error.retryAfterMs,
78
+ };
79
+ }
70
80
  return { error: errorMessage(error) };
71
81
  }
82
+ // one response builder for every refusal, so a rejection that tells the caller
83
+ // when to come back always says so in the header an HTTP client already reads.
84
+ function errorResponse(error) {
85
+ const body = errorBody(error);
86
+ const retryAfterMs = body.retryAfterMs;
87
+ // whole seconds on the wire, rounded up: rounding down invites the caller
88
+ // back while the window it has to wait out is still open.
89
+ return json(body, statusOf(error), typeof retryAfterMs === 'number'
90
+ ? { 'retry-after': String(Math.ceil(retryAfterMs / 1000)) }
91
+ : undefined);
92
+ }
72
93
  function requestError(message, status = 400) {
73
94
  return Object.assign(new Error(message), { status });
74
95
  }
@@ -209,7 +230,7 @@ export function createSyncWorker(config) {
209
230
  forwardedBody = { claims, body };
210
231
  }
211
232
  catch (error) {
212
- return json(errorBody(error), statusOf(error));
233
+ return errorResponse(error);
213
234
  }
214
235
  }
215
236
  }
@@ -264,10 +285,12 @@ export function createSyncDurableObject(config) {
264
285
  #executor;
265
286
  #executorBeforeCommitFault = null;
266
287
  #bootID = crypto.randomUUID();
288
+ #initSkipped = false;
267
289
  #lastRequestAt = 0;
268
290
  #hibernations = 0;
269
291
  #dropNextPushResponse = false;
270
292
  #counters = freshCounters();
293
+ #sqlBilling = { rowsRead: 0, rowsWritten: 0 };
271
294
  #pulling = new Set();
272
295
  #wakeOrigins = new Set();
273
296
  #wakeRecipients = new Set();
@@ -292,11 +315,15 @@ export function createSyncDurableObject(config) {
292
315
  constructor(ctx, env) {
293
316
  super(ctx, env);
294
317
  const recordRowsWritten = (rows) => {
318
+ this.#sqlBilling.rowsWritten += rows;
295
319
  if (this.#recordingIngestBillable)
296
320
  this.#ingestBreaker.recordBillable(rows);
297
321
  };
298
- this.#engineDb = new SqlStorageSyncDb(ctx.storage.sql, recordRowsWritten);
299
- this.#directSql = new SqlStorageDirect(ctx.storage.sql, recordRowsWritten);
322
+ const recordRowsRead = (rows) => {
323
+ this.#sqlBilling.rowsRead += rows;
324
+ };
325
+ this.#engineDb = new SqlStorageSyncDb(ctx.storage.sql, recordRowsWritten, recordRowsRead);
326
+ this.#directSql = new SqlStorageDirect(ctx.storage.sql, recordRowsWritten, recordRowsRead);
300
327
  this.#mutatorSql = new SqlStorageMutatorTransaction(this.#directSql, (ast, format) => this.#wasm(() => compileQuery(ast, format)), config.transactionQueryBudget);
301
328
  const database = {
302
329
  dialect: 'sqlite',
@@ -311,7 +338,16 @@ export function createSyncDurableObject(config) {
311
338
  }
312
339
  return this.#mutatorSql.exec(sql, params, metadata);
313
340
  },
314
- query: (sql, params) => this.#mutatorSql.query(sql, params),
341
+ query: (sql, params) => {
342
+ // exact helper statements use sqlite returning so the executor
343
+ // can prove the physical primary keys that changed. Keep the
344
+ // host's pre-commit fault boundary after those writes too.
345
+ if (/\b(?:INSERT|UPDATE|DELETE|REPLACE)\b/i.test(sql) &&
346
+ /\bRETURNING\b/i.test(sql)) {
347
+ applicationWrite = true;
348
+ }
349
+ return this.#mutatorSql.query(sql, params);
350
+ },
315
351
  queryAst: (ast, format, queryName) => this.#mutatorSql.queryAst(ast, format, queryName),
316
352
  };
317
353
  const value = await work(tx);
@@ -344,19 +380,35 @@ export function createSyncDurableObject(config) {
344
380
  : null;
345
381
  ctx.blockConcurrencyWhile(async () => {
346
382
  ctx.storage.transactionSync(() => {
347
- config.initialize(this.#directSql);
348
383
  this.#directSql.exec(`CREATE TABLE IF NOT EXISTS _zsync_host_control (
349
384
  key TEXT PRIMARY KEY,
350
385
  value TEXT NOT NULL
351
386
  )`);
352
- this.#directSql.exec("INSERT OR IGNORE INTO _zsync_host_control (key, value) VALUES ('writerEnabled', '1')");
387
+ // one durable fingerprint gates the whole schema pass. hibernation
388
+ // reconstructs this object every few idle minutes, and re-running
389
+ // consumer DDL plus both engine inits against an already-current
390
+ // database costs ~1.5k billable reads per wake. everything the pass
391
+ // depends on is in the fingerprint, so a build that changes any DDL
392
+ // surface re-runs it exactly once per namespace.
393
+ const initFingerprint = JSON.stringify([
394
+ config.hostVersion,
395
+ this.#wasm(() => engine_schema_revision()),
396
+ config.schema,
397
+ config.initialize.toString(),
398
+ ]);
399
+ this.#initSkipped = this.#controlGet('initFingerprint') === initFingerprint;
400
+ if (!this.#initSkipped) {
401
+ config.initialize(this.#directSql);
402
+ this.#directSql.exec("INSERT OR IGNORE INTO _zsync_host_control (key, value) VALUES ('writerEnabled', '1')");
403
+ this.#wasm(() => engine_init_schema(this.#engineDb, config.schema));
404
+ this.#wasm(() => engine_init_query_schema(this.#engineDb));
405
+ this.#controlSet('initFingerprint', initFingerprint);
406
+ }
353
407
  const ingestBreakerReason = this.#controlGet('ingestBreakerReason');
354
408
  if (ingestBreakerReason === 'ingestBudgetExceeded' ||
355
409
  ingestBreakerReason === 'ingestCursorStalled') {
356
410
  this.#ingestBreaker.restore(ingestBreakerReason, Number(this.#controlGet('ingestBreakerRetryAt')), Number(this.#controlGet('ingestBreakerTrips')));
357
411
  }
358
- this.#wasm(() => engine_init_schema(this.#engineDb, config.schema));
359
- this.#wasm(() => engine_init_query_schema(this.#engineDb));
360
412
  });
361
413
  });
362
414
  }
@@ -378,6 +430,7 @@ export function createSyncDurableObject(config) {
378
430
  this.#bootID = crypto.randomUUID();
379
431
  this.#hibernations++;
380
432
  this.#counters = freshCounters();
433
+ this.#sqlBilling = { rowsRead: 0, rowsWritten: 0 };
381
434
  this.#pulling.clear();
382
435
  this.#wakeOrigins.clear();
383
436
  this.#wakeRecipients.clear();
@@ -402,8 +455,12 @@ export function createSyncDurableObject(config) {
402
455
  return row?.value ?? null;
403
456
  }
404
457
  #controlSet(key, value) {
458
+ // the WHERE guard makes a same-value set free: upstreamPath arrives on
459
+ // every forwarded request, and an unguarded upsert billed one row
460
+ // written per request for a value that almost never changes.
405
461
  this.#directSql.exec('INSERT INTO _zsync_host_control (key, value) VALUES (?, ?) ' +
406
- 'ON CONFLICT(key) DO UPDATE SET value = excluded.value', [key, value]);
462
+ 'ON CONFLICT(key) DO UPDATE SET value = excluded.value ' +
463
+ 'WHERE value <> excluded.value', [key, value]);
407
464
  }
408
465
  #controlDelete(...keys) {
409
466
  if (keys.length === 0)
@@ -1106,8 +1163,7 @@ export function createSyncDurableObject(config) {
1106
1163
  return json({ pushResponse: delegatedResponse });
1107
1164
  }
1108
1165
  catch (error) {
1109
- const status = statusOf(error);
1110
- return json(errorBody(error), status);
1166
+ return errorResponse(error);
1111
1167
  }
1112
1168
  }
1113
1169
  try {
@@ -1203,7 +1259,7 @@ export function createSyncDurableObject(config) {
1203
1259
  totalMs: performance.now() - started,
1204
1260
  resetReason: null,
1205
1261
  });
1206
- return json({ error: errorMessage(error) }, status);
1262
+ return errorResponse(error);
1207
1263
  }
1208
1264
  }
1209
1265
  // The hub for this incarnation, built on first use. Null unless the
@@ -1328,12 +1384,15 @@ export function createSyncDurableObject(config) {
1328
1384
  #admin(route, request, upstreamPath) {
1329
1385
  if (route === '/admin/health')
1330
1386
  return json({ ok: true });
1387
+ if (route === '/admin/sql-billing')
1388
+ return json({ ...this.#sqlBilling });
1331
1389
  if (route === '/admin/upstream-write-budget' && request.method === 'GET')
1332
1390
  return this.#upstreamWriteBudgetStatus();
1333
1391
  if (route === '/admin/status') {
1334
1392
  const heap = performance.memory;
1335
1393
  return this.ctx.storage.getAlarm().then((upstreamAlarmAt) => json({
1336
1394
  bootID: this.#bootID,
1395
+ initSkipped: this.#initSkipped,
1337
1396
  idleTeardownMs,
1338
1397
  hibernations: this.#hibernations,
1339
1398
  databaseSizeBytes: this.ctx.storage.sql.databaseSize,
@@ -1345,6 +1404,7 @@ export function createSyncDurableObject(config) {
1345
1404
  heapTotalBytes: heap?.totalJSHeapSize ?? null,
1346
1405
  heapLimitBytes: heap?.jsHeapSizeLimit ?? null,
1347
1406
  engine: this.#engineStateBestEffort(),
1407
+ sqlBillingSinceBoot: this.#sqlBilling,
1348
1408
  counters: this.#counters,
1349
1409
  ingestBreaker: this.#ingestBreaker.status(),
1350
1410
  }));
@@ -1391,7 +1451,7 @@ export function createSyncDurableObject(config) {
1391
1451
  });
1392
1452
  }
1393
1453
  catch (error) {
1394
- return json(errorBody(error), statusOf(error));
1454
+ return errorResponse(error);
1395
1455
  }
1396
1456
  })();
1397
1457
  }
@@ -1486,7 +1546,7 @@ export function createSyncDurableObject(config) {
1486
1546
  return json({ ok: true, applied });
1487
1547
  }
1488
1548
  catch (error) {
1489
- return json(errorBody(error), statusOf(error));
1549
+ return errorResponse(error);
1490
1550
  }
1491
1551
  }
1492
1552
  if ((route === '/pull' || route === '/push') && request.method === 'POST') {
@@ -1495,14 +1555,14 @@ export function createSyncDurableObject(config) {
1495
1555
  forwarded = await forwardedSyncRequest(request);
1496
1556
  }
1497
1557
  catch (error) {
1498
- return json(errorBody(error), statusOf(error));
1558
+ return errorResponse(error);
1499
1559
  }
1500
1560
  // pull and push both establish the upstream schema and snapshot barrier.
1501
1561
  try {
1502
1562
  await this.#ingest(upstreamPath);
1503
1563
  }
1504
1564
  catch (error) {
1505
- return json(errorBody(error), statusOf(error));
1565
+ return errorResponse(error);
1506
1566
  }
1507
1567
  if (route === '/pull') {
1508
1568
  return this.#pull(forwarded.request, forwarded.claims, namespace);
@@ -27,8 +27,9 @@ export declare function decodeSqlParams(value: unknown): unknown[];
27
27
  export declare class SqlStorageSyncDb implements JsSyncDb {
28
28
  private readonly sql;
29
29
  private readonly recordRowsWritten;
30
+ private readonly recordRowsRead;
30
31
  readonly stats: AdapterStats;
31
- constructor(sql: SqlStorage, recordRowsWritten?: (rows: number) => void);
32
+ constructor(sql: SqlStorage, recordRowsWritten?: (rows: number) => void, recordRowsRead?: (rows: number) => void);
32
33
  resetStats(): void;
33
34
  exec(sql: string, params: WireValue[]): void;
34
35
  query(sql: string, params: WireValue[]): WireRow[];
@@ -37,7 +38,8 @@ export declare class SqlStorageSyncDb implements JsSyncDb {
37
38
  export declare class SqlStorageDirect implements SyncSql {
38
39
  private readonly sql;
39
40
  private readonly recordRowsWritten;
40
- constructor(sql: SqlStorage, recordRowsWritten?: (rows: number) => void);
41
+ private readonly recordRowsRead;
42
+ constructor(sql: SqlStorage, recordRowsWritten?: (rows: number) => void, recordRowsRead?: (rows: number) => void);
41
43
  exec(sql: string, params?: readonly unknown[], _metadata?: SqlStatementMetadata): ExecResult;
42
44
  query<Row extends Record<string, unknown> = Record<string, unknown>>(sql: string, params?: readonly unknown[]): Row[];
43
45
  }
@@ -98,10 +98,12 @@ function encodeResult(value) {
98
98
  export class SqlStorageSyncDb {
99
99
  sql;
100
100
  recordRowsWritten;
101
+ recordRowsRead;
101
102
  stats = { execCalls: 0, queryCalls: 0, sqlMs: 0 };
102
- constructor(sql, recordRowsWritten = () => { }) {
103
+ constructor(sql, recordRowsWritten = () => { }, recordRowsRead = () => { }) {
103
104
  this.sql = sql;
104
105
  this.recordRowsWritten = recordRowsWritten;
106
+ this.recordRowsRead = recordRowsRead;
105
107
  }
106
108
  resetStats() {
107
109
  this.stats.execCalls = 0;
@@ -111,14 +113,14 @@ export class SqlStorageSyncDb {
111
113
  exec(sql, params) {
112
114
  assertHostSql(sql);
113
115
  const start = performance.now();
114
- trackBillableCursorRows(this.sql.exec(sql, ...params.map((value) => decodeBinding(value))), this.recordRowsWritten);
116
+ trackBillableCursorRows(this.sql.exec(sql, ...params.map((value) => decodeBinding(value))), this.recordRowsWritten, this.recordRowsRead);
115
117
  this.stats.execCalls++;
116
118
  this.stats.sqlMs += performance.now() - start;
117
119
  }
118
120
  query(sql, params) {
119
121
  assertHostSql(sql);
120
122
  const start = performance.now();
121
- const cursor = trackBillableCursorRows(this.sql.exec(sql, ...params.map((value) => decodeBinding(value))), this.recordRowsWritten);
123
+ const cursor = trackBillableCursorRows(this.sql.exec(sql, ...params.map((value) => decodeBinding(value))), this.recordRowsWritten, this.recordRowsRead);
122
124
  const columns = [...cursor.columnNames];
123
125
  // A SqlStorage cursor must be fully consumed before any await. Returning a
124
126
  // materialized array here makes that property structural.
@@ -135,19 +137,21 @@ export class SqlStorageSyncDb {
135
137
  export class SqlStorageDirect {
136
138
  sql;
137
139
  recordRowsWritten;
138
- constructor(sql, recordRowsWritten = () => { }) {
140
+ recordRowsRead;
141
+ constructor(sql, recordRowsWritten = () => { }, recordRowsRead = () => { }) {
139
142
  this.sql = sql;
140
143
  this.recordRowsWritten = recordRowsWritten;
144
+ this.recordRowsRead = recordRowsRead;
141
145
  }
142
146
  exec(sql, params = [], _metadata) {
143
147
  assertHostSql(sql);
144
- trackBillableCursorRows(this.sql.exec(sql, ...params), this.recordRowsWritten).toArray();
145
- const changes = Number(this.sql.exec('SELECT changes() AS changes').one()?.changes ?? 0);
148
+ trackBillableCursorRows(this.sql.exec(sql, ...params), this.recordRowsWritten, this.recordRowsRead).toArray();
149
+ const changes = Number(trackBillableCursorRows(this.sql.exec('SELECT changes() AS changes'), this.recordRowsWritten, this.recordRowsRead).one()?.changes ?? 0);
146
150
  return { changes };
147
151
  }
148
152
  query(sql, params = []) {
149
153
  assertHostSql(sql);
150
- return trackBillableCursorRows(this.sql.exec(sql, ...params), this.recordRowsWritten).toArray();
154
+ return trackBillableCursorRows(this.sql.exec(sql, ...params), this.recordRowsWritten, this.recordRowsRead).toArray();
151
155
  }
152
156
  }
153
157
  /**
package/dist/wasm.d.ts CHANGED
@@ -18,5 +18,6 @@ export declare const engine_prune: (db: any, retain_changes: string) => void;
18
18
  export declare const engine_push_validate: (body: any) => any;
19
19
  export declare const engine_read_snapshot_progress: (db: any) => any;
20
20
  export declare const engine_record_app_error: (db: any, client_group_id: string, client_id: string, mutation_id: string, user_id: string) => void;
21
+ export declare const engine_schema_revision: () => string;
21
22
  export declare const engine_state: (db: any) => any;
22
23
  export declare const engine_version: () => string;
package/dist/wasm.js CHANGED
@@ -35,5 +35,6 @@ export const engine_prune = withWasm(generated.engine_prune);
35
35
  export const engine_push_validate = withWasm(generated.engine_push_validate);
36
36
  export const engine_read_snapshot_progress = withWasm(generated.engine_read_snapshot_progress);
37
37
  export const engine_record_app_error = withWasm(generated.engine_record_app_error);
38
+ export const engine_schema_revision = withWasm(generated.engine_schema_revision);
38
39
  export const engine_state = withWasm(generated.engine_state);
39
40
  export const engine_version = withWasm(generated.engine_version);
@@ -40,6 +40,7 @@ export declare class IngestCircuitBreaker {
40
40
  reopen(): void;
41
41
  }
42
42
  type BillableCursor = {
43
+ rowsRead?: number;
43
44
  rowsWritten?: number;
44
45
  next?: (...args: unknown[]) => unknown;
45
46
  one?: (...args: unknown[]) => unknown;
@@ -47,7 +48,7 @@ type BillableCursor = {
47
48
  raw?: (...args: unknown[]) => unknown;
48
49
  [Symbol.iterator]?: () => Iterator<unknown>;
49
50
  };
50
- export declare function trackBillableCursorRows<Cursor extends BillableCursor>(cursor: Cursor, record: (rows: number) => void): Cursor;
51
+ export declare function trackBillableCursorRows<Cursor extends BillableCursor>(cursor: Cursor, recordWritten: (rows: number) => void, recordRead?: (rows: number) => void): Cursor;
51
52
  export declare function retryDelayMs(attempt: number, initialBackoffMs: number, maxBackoffMs: number): number;
52
53
  export declare function shouldRetryDelegatedPush(responseStatus: number | null, attempt: number, maxAttempts: number): boolean;
53
54
  export {};
@@ -122,17 +122,22 @@ export class IngestCircuitBreaker {
122
122
  this.recovered();
123
123
  }
124
124
  }
125
- export function trackBillableCursorRows(cursor, record) {
125
+ export function trackBillableCursorRows(cursor, recordWritten, recordRead = () => { }) {
126
126
  if (!cursor || typeof cursor !== 'object')
127
127
  return cursor;
128
- let accountedRows = 0;
128
+ let accountedWritten = 0;
129
+ let accountedRead = 0;
129
130
  const account = () => {
130
- const current = Number(cursor.rowsWritten ?? 0);
131
- if (!Number.isSafeInteger(current) || current <= accountedRows)
132
- return;
133
- const delta = current - accountedRows;
134
- accountedRows = current;
135
- record(delta);
131
+ const written = Number(cursor.rowsWritten ?? 0);
132
+ if (Number.isSafeInteger(written) && written > accountedWritten) {
133
+ recordWritten(written - accountedWritten);
134
+ accountedWritten = written;
135
+ }
136
+ const read = Number(cursor.rowsRead ?? 0);
137
+ if (Number.isSafeInteger(read) && read > accountedRead) {
138
+ recordRead(read - accountedRead);
139
+ accountedRead = read;
140
+ }
136
141
  };
137
142
  const wrapIterator = (iterator) => {
138
143
  const next = iterator.next.bind(iterator);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orez-sync-cf-host",
3
- "version": "0.12.1",
3
+ "version": "0.12.3",
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": {
@@ -58,7 +58,7 @@
58
58
  "test:large-data": "bun run build:dist && bun large-data-test.mjs"
59
59
  },
60
60
  "dependencies": {
61
- "orez-sync-executor": "0.12.1"
61
+ "orez-sync-executor": "0.12.3"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@cloudflare/workers-types": "4.20260617.1",