orez-sync-cf-host 0.12.14 → 0.12.16
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/README.md +6 -1
- package/dist/host.d.ts +3 -1
- package/dist/host.js +131 -53
- package/dist/sql-storage-adapter.js +1 -1
- package/dist/write-safeguards.d.ts +0 -11
- package/dist/write-safeguards.js +0 -59
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -168,7 +168,12 @@ channel disconnects, it retries in the background while the safety poll
|
|
|
168
168
|
continues to provide convergence.
|
|
169
169
|
|
|
170
170
|
`/admin/status` reports a boot ID, hibernation simulation count, connected wake
|
|
171
|
-
sockets, durable database size, engine watermark/floor,
|
|
171
|
+
sockets, durable database size, engine watermark/floor, aggregate counters, and
|
|
172
|
+
the most recent coalesced wake batch. That wake record includes the sorted
|
|
173
|
+
upstream table names which caused the batch plus its socket, recipient, timing,
|
|
174
|
+
and frame counts. Local-only pushes have no upstream table attribution and
|
|
175
|
+
therefore report an empty table list. The same fields are emitted in the
|
|
176
|
+
structured `sync_wake` log.
|
|
172
177
|
After the configured idle gap (5 seconds in the harness deployment), the local
|
|
173
178
|
deterministic model resets in-memory state and changes boot ID while retaining
|
|
174
179
|
SQLite state and sockets. This models the harness idle-teardown pattern; it
|
package/dist/host.d.ts
CHANGED
|
@@ -6,7 +6,9 @@ import type { Schema } from '@rocicorp/zero';
|
|
|
6
6
|
* Durable Object receives normalized claims inside the binding request body so
|
|
7
7
|
* observability systems cannot record them as request-header metadata.
|
|
8
8
|
*/
|
|
9
|
-
export declare function createSyncWorker<Env extends SyncHostEnv, S extends Schema = Schema>(config: SyncHostConfig<Env, S>): ExportedHandler<Env
|
|
9
|
+
export declare function createSyncWorker<Env extends SyncHostEnv, S extends Schema = Schema>(config: SyncHostConfig<Env, S>): ExportedHandler<Env> & {
|
|
10
|
+
notify(env: Env, namespace: string): Promise<Response>;
|
|
11
|
+
};
|
|
10
12
|
export interface SyncDurableObjectConstructor<Env extends SyncHostEnv> {
|
|
11
13
|
new (ctx: DurableObjectState, env: Env): DurableObject<Env>;
|
|
12
14
|
}
|
package/dist/host.js
CHANGED
|
@@ -9,6 +9,8 @@ import { engine_authorize_realtime_subscription, engine_apply_snapshot_changes,
|
|
|
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';
|
|
12
|
+
const NOTIFY_IF_SUBSCRIBED_HEADER = 'x-orez-notify-if-subscribed';
|
|
13
|
+
const WAKE_SUBSCRIBER_TAG = 'orez:wake-subscriber';
|
|
12
14
|
// A websocket upgrade is a GET, so the authenticated identity cannot ride the
|
|
13
15
|
// body the way /pull and /push carry their claims. It rides a private header
|
|
14
16
|
// the worker always deletes from the incoming request before setting its own,
|
|
@@ -16,6 +18,15 @@ const UPSTREAM_PATH_HEADER = 'x-orez-sync-upstream-path';
|
|
|
16
18
|
const IDENTITY_HEADER = 'x-orez-sync-identity';
|
|
17
19
|
const DEFAULT_SNAPSHOT_PAGE_ROWS = 2_000;
|
|
18
20
|
const MIN_SNAPSHOT_PAGE_ROWS = 100;
|
|
21
|
+
function upstreamBatchTables(batch) {
|
|
22
|
+
const tables = new Set();
|
|
23
|
+
for (const change of batch.changes) {
|
|
24
|
+
if (typeof change?.tableName === 'string' && change.tableName.length > 0) {
|
|
25
|
+
tables.add(change.tableName);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return tables;
|
|
29
|
+
}
|
|
19
30
|
function freshCounters() {
|
|
20
31
|
return {
|
|
21
32
|
pulls: 0,
|
|
@@ -171,6 +182,17 @@ function socketCloseQuietly(socket, code, reason) {
|
|
|
171
182
|
export function createSyncWorker(config) {
|
|
172
183
|
validateSyncHostConfig(config);
|
|
173
184
|
const allowedOrigins = new Set(config.allowedOrigins ?? []);
|
|
185
|
+
const upstreamPath = (namespace) => {
|
|
186
|
+
if (!config.upstream)
|
|
187
|
+
return null;
|
|
188
|
+
const path = typeof config.upstream.namespacePath === 'function'
|
|
189
|
+
? config.upstream.namespacePath(namespace)
|
|
190
|
+
: config.upstream.namespacePath;
|
|
191
|
+
if (!path.startsWith('/')) {
|
|
192
|
+
throw new Error('upstream namespacePath must be an absolute path');
|
|
193
|
+
}
|
|
194
|
+
return path.replace(/\/$/, '');
|
|
195
|
+
};
|
|
174
196
|
const corsHeaders = (origin) => ({
|
|
175
197
|
'access-control-allow-origin': origin,
|
|
176
198
|
vary: 'origin',
|
|
@@ -266,6 +288,7 @@ export function createSyncWorker(config) {
|
|
|
266
288
|
headers.delete(NAMESPACE_HEADER);
|
|
267
289
|
headers.delete(UPSTREAM_PATH_HEADER);
|
|
268
290
|
headers.delete(IDENTITY_HEADER);
|
|
291
|
+
headers.delete(NOTIFY_IF_SUBSCRIBED_HEADER);
|
|
269
292
|
let forwardedBody = null;
|
|
270
293
|
// /wake and /realtime/produce are both websocket upgrades and have no
|
|
271
294
|
// body to put claims in. wake authentication is normalized from its
|
|
@@ -302,14 +325,13 @@ export function createSyncWorker(config) {
|
|
|
302
325
|
if (wakeUserID)
|
|
303
326
|
headers.set(IDENTITY_HEADER, encodeURIComponent(wakeUserID));
|
|
304
327
|
headers.set(NAMESPACE_HEADER, await namespaceHash(namespace));
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
headers.set(UPSTREAM_PATH_HEADER, namespacePath.replace(/\/$/, ''));
|
|
328
|
+
try {
|
|
329
|
+
const path = upstreamPath(namespace);
|
|
330
|
+
if (path !== null)
|
|
331
|
+
headers.set(UPSTREAM_PATH_HEADER, path);
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
return errorResponse(error);
|
|
313
335
|
}
|
|
314
336
|
const forwarded = forwardedBody
|
|
315
337
|
? jsonBodyRequest(request, headers, forwardedBody)
|
|
@@ -319,6 +341,23 @@ export function createSyncWorker(config) {
|
|
|
319
341
|
};
|
|
320
342
|
return withCors(await handle());
|
|
321
343
|
},
|
|
344
|
+
async notify(env, namespace) {
|
|
345
|
+
if (!namespace)
|
|
346
|
+
throw new TypeError('sync notification namespace is required');
|
|
347
|
+
const path = upstreamPath(namespace);
|
|
348
|
+
if (path === null)
|
|
349
|
+
throw new Error('sync notifications require an upstream feed');
|
|
350
|
+
const headers = new Headers({
|
|
351
|
+
[NAMESPACE_HEADER]: await namespaceHash(namespace),
|
|
352
|
+
[UPSTREAM_PATH_HEADER]: path,
|
|
353
|
+
[NOTIFY_IF_SUBSCRIBED_HEADER]: '1',
|
|
354
|
+
});
|
|
355
|
+
const request = new Request('https://orez-sync.internal/namespace/notify', {
|
|
356
|
+
method: 'POST',
|
|
357
|
+
headers,
|
|
358
|
+
});
|
|
359
|
+
return env.SYNC_DO.get(env.SYNC_DO.idFromName(namespace)).fetch(request);
|
|
360
|
+
},
|
|
322
361
|
};
|
|
323
362
|
}
|
|
324
363
|
/** Create the namespace Durable Object class for one bundled consumer config. */
|
|
@@ -352,6 +391,7 @@ export function createSyncDurableObject(config) {
|
|
|
352
391
|
#executor;
|
|
353
392
|
#executorBeforeCommitFault = null;
|
|
354
393
|
#bootID = crypto.randomUUID();
|
|
394
|
+
#initialized = false;
|
|
355
395
|
#initSkipped = false;
|
|
356
396
|
#lastRequestAt = 0;
|
|
357
397
|
#hibernations = 0;
|
|
@@ -361,7 +401,9 @@ export function createSyncDurableObject(config) {
|
|
|
361
401
|
#pulling = new Set();
|
|
362
402
|
#wakeOrigins = new Set();
|
|
363
403
|
#wakeRecipients = new Set();
|
|
404
|
+
#wakeTables = new Set();
|
|
364
405
|
#wakePromise = null;
|
|
406
|
+
#lastWake = null;
|
|
365
407
|
// Streaming fields. Null when the namespace configures no manifest, which
|
|
366
408
|
// is every wake-only deployment: no hub is built and nothing below runs.
|
|
367
409
|
#realtime = null;
|
|
@@ -445,39 +487,42 @@ export function createSyncDurableObject(config) {
|
|
|
445
487
|
schema: config.schema,
|
|
446
488
|
})
|
|
447
489
|
: null;
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
490
|
+
}
|
|
491
|
+
#initialize() {
|
|
492
|
+
if (this.#initialized)
|
|
493
|
+
return;
|
|
494
|
+
this.ctx.storage.transactionSync(() => {
|
|
495
|
+
this.#directSql.exec(`CREATE TABLE IF NOT EXISTS _zsync_host_control (
|
|
496
|
+
key TEXT PRIMARY KEY,
|
|
497
|
+
value TEXT NOT NULL
|
|
498
|
+
)`);
|
|
499
|
+
// one durable fingerprint gates the whole schema pass. hibernation
|
|
500
|
+
// reconstructs this object every few idle minutes, and re-running
|
|
501
|
+
// consumer DDL plus both engine inits against an already-current
|
|
502
|
+
// database costs ~1.5k billable reads per wake. everything the pass
|
|
503
|
+
// depends on is in the fingerprint, so a build that changes any DDL
|
|
504
|
+
// surface re-runs it exactly once per namespace.
|
|
505
|
+
const initFingerprint = JSON.stringify([
|
|
506
|
+
config.hostVersion,
|
|
507
|
+
this.#wasm(() => engine_schema_revision()),
|
|
508
|
+
config.schema,
|
|
509
|
+
config.initialize.toString(),
|
|
510
|
+
]);
|
|
511
|
+
this.#initSkipped = this.#controlGet('initFingerprint') === initFingerprint;
|
|
512
|
+
if (!this.#initSkipped) {
|
|
513
|
+
config.initialize(this.#directSql);
|
|
514
|
+
this.#directSql.exec("INSERT OR IGNORE INTO _zsync_host_control (key, value) VALUES ('writerEnabled', '1')");
|
|
515
|
+
this.#wasm(() => engine_init_schema(this.#engineDb, config.schema));
|
|
516
|
+
this.#wasm(() => engine_init_query_schema(this.#engineDb));
|
|
517
|
+
this.#controlSet('initFingerprint', initFingerprint);
|
|
518
|
+
}
|
|
519
|
+
const ingestBreakerReason = this.#controlGet('ingestBreakerReason');
|
|
520
|
+
if (ingestBreakerReason === 'ingestBudgetExceeded' ||
|
|
521
|
+
ingestBreakerReason === 'ingestCursorStalled') {
|
|
522
|
+
this.#ingestBreaker.restore(ingestBreakerReason, Number(this.#controlGet('ingestBreakerRetryAt')), Number(this.#controlGet('ingestBreakerTrips')));
|
|
523
|
+
}
|
|
480
524
|
});
|
|
525
|
+
this.#initialized = true;
|
|
481
526
|
}
|
|
482
527
|
#armUpstreamAlarm() {
|
|
483
528
|
if (!config.upstream)
|
|
@@ -501,7 +546,9 @@ export function createSyncDurableObject(config) {
|
|
|
501
546
|
this.#pulling.clear();
|
|
502
547
|
this.#wakeOrigins.clear();
|
|
503
548
|
this.#wakeRecipients.clear();
|
|
549
|
+
this.#wakeTables.clear();
|
|
504
550
|
this.#wakePromise = null;
|
|
551
|
+
this.#lastWake = null;
|
|
505
552
|
// Real hibernation reconstructs the object, so the hub and every
|
|
506
553
|
// connection handle are gone while the sockets stay open. Modelling
|
|
507
554
|
// that here is what makes rehydration reachable from a test: leaving
|
|
@@ -759,6 +806,7 @@ export function createSyncDurableObject(config) {
|
|
|
759
806
|
let pendingPage = null;
|
|
760
807
|
let snapshotPageLimit = DEFAULT_SNAPSHOT_PAGE_ROWS;
|
|
761
808
|
let snapshotCompleted = false;
|
|
809
|
+
const changedTables = new Set();
|
|
762
810
|
for (;;) {
|
|
763
811
|
if (progress?.state === 'paging') {
|
|
764
812
|
const activeProgress = progress;
|
|
@@ -781,6 +829,7 @@ export function createSyncDurableObject(config) {
|
|
|
781
829
|
}, () => this.ctx.storage.transactionSync(() => this.#wasm(() => engine_apply_snapshot_page(this.#engineDb, config.schema, activeProgress.generation, table, pageToApply.rows, pageToApply.nextCursor))));
|
|
782
830
|
total += pageToApply.rows.length;
|
|
783
831
|
this.#recordIngestLogicalRows(pageToApply.rows.length);
|
|
832
|
+
changedTables.add(table);
|
|
784
833
|
progress = nextProgress;
|
|
785
834
|
pendingPage = null;
|
|
786
835
|
}
|
|
@@ -818,6 +867,8 @@ export function createSyncDurableObject(config) {
|
|
|
818
867
|
if (!Number.isSafeInteger(batch.watermark) || !Array.isArray(batch.changes)) {
|
|
819
868
|
throw new Error('invalid upstream changes response');
|
|
820
869
|
}
|
|
870
|
+
for (const table of upstreamBatchTables(batch))
|
|
871
|
+
changedTables.add(table);
|
|
821
872
|
this.#resetSnapshotBillingWindow();
|
|
822
873
|
const result = this.#withIngestBilling({
|
|
823
874
|
phase: 'snapshot_catchup',
|
|
@@ -884,6 +935,8 @@ export function createSyncDurableObject(config) {
|
|
|
884
935
|
if (!Number.isSafeInteger(batch.watermark) || !Array.isArray(batch.changes)) {
|
|
885
936
|
throw new Error('invalid upstream changes response');
|
|
886
937
|
}
|
|
938
|
+
for (const table of upstreamBatchTables(batch))
|
|
939
|
+
changedTables.add(table);
|
|
887
940
|
const result = this.#withIngestBilling({
|
|
888
941
|
phase: 'changes',
|
|
889
942
|
cursor,
|
|
@@ -921,7 +974,7 @@ export function createSyncDurableObject(config) {
|
|
|
921
974
|
this.#recoverIngestBreaker();
|
|
922
975
|
const endingWatermark = this.#engineState().watermark;
|
|
923
976
|
if (snapshotCompleted || total > 0 || endingWatermark !== startingWatermark) {
|
|
924
|
-
await this.#enqueueWake('__upstream__');
|
|
977
|
+
await this.#enqueueWake('__upstream__', changedTables);
|
|
925
978
|
}
|
|
926
979
|
return total;
|
|
927
980
|
})().finally(() => {
|
|
@@ -978,9 +1031,11 @@ export function createSyncDurableObject(config) {
|
|
|
978
1031
|
...fields,
|
|
979
1032
|
}));
|
|
980
1033
|
}
|
|
981
|
-
#enqueueWake(originClientID) {
|
|
1034
|
+
#enqueueWake(originClientID, tables = []) {
|
|
982
1035
|
this.#wakeOrigins.add(originClientID);
|
|
983
|
-
for (const
|
|
1036
|
+
for (const table of tables)
|
|
1037
|
+
this.#wakeTables.add(table);
|
|
1038
|
+
for (const socket of this.ctx.getWebSockets(WAKE_SUBSCRIBER_TAG)) {
|
|
984
1039
|
const attachment = socketAttachment(socket);
|
|
985
1040
|
if (!attachment ||
|
|
986
1041
|
attachment.clientID === originClientID ||
|
|
@@ -1001,9 +1056,11 @@ export function createSyncDurableObject(config) {
|
|
|
1001
1056
|
this.#wakeOrigins = new Set();
|
|
1002
1057
|
const recipients = this.#wakeRecipients;
|
|
1003
1058
|
this.#wakeRecipients = new Set();
|
|
1059
|
+
const tables = [...this.#wakeTables].sort();
|
|
1060
|
+
this.#wakeTables = new Set();
|
|
1004
1061
|
this.#counters.wakeBatches++;
|
|
1005
1062
|
let sent = 0;
|
|
1006
|
-
const sockets = this.ctx.getWebSockets();
|
|
1063
|
+
const sockets = this.ctx.getWebSockets(WAKE_SUBSCRIBER_TAG);
|
|
1007
1064
|
for (const socket of recipients) {
|
|
1008
1065
|
try {
|
|
1009
1066
|
socket.send('wake');
|
|
@@ -1015,15 +1072,21 @@ export function createSyncDurableObject(config) {
|
|
|
1015
1072
|
// a race here is advisory and carries no correctness weight.
|
|
1016
1073
|
}
|
|
1017
1074
|
}
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1075
|
+
const wakeStatus = {
|
|
1076
|
+
at: Date.now(),
|
|
1077
|
+
tables,
|
|
1021
1078
|
socketCount: sockets.length,
|
|
1022
1079
|
originCount: origins.size,
|
|
1023
1080
|
sent,
|
|
1024
1081
|
eligibleRecipients: recipients.size,
|
|
1025
1082
|
coalesceMs: fanoutStarted - queuedAt,
|
|
1026
1083
|
fanoutMs: performance.now() - fanoutStarted,
|
|
1084
|
+
};
|
|
1085
|
+
this.#lastWake = wakeStatus;
|
|
1086
|
+
console.log(JSON.stringify({
|
|
1087
|
+
event: 'sync_wake',
|
|
1088
|
+
hostVersion: config.hostVersion,
|
|
1089
|
+
...wakeStatus,
|
|
1027
1090
|
}));
|
|
1028
1091
|
})().finally(() => {
|
|
1029
1092
|
this.#wakePromise = null;
|
|
@@ -1439,7 +1502,7 @@ export function createSyncDurableObject(config) {
|
|
|
1439
1502
|
clientID,
|
|
1440
1503
|
identity,
|
|
1441
1504
|
});
|
|
1442
|
-
this.ctx.acceptWebSocket(server, [`client:${clientID}`]);
|
|
1505
|
+
this.ctx.acceptWebSocket(server, [WAKE_SUBSCRIBER_TAG, `client:${clientID}`]);
|
|
1443
1506
|
if (identity) {
|
|
1444
1507
|
const host = this.#realtimeHost();
|
|
1445
1508
|
if (host) {
|
|
@@ -1474,7 +1537,7 @@ export function createSyncDurableObject(config) {
|
|
|
1474
1537
|
idleTeardownMs,
|
|
1475
1538
|
hibernations: this.#hibernations,
|
|
1476
1539
|
databaseSizeBytes: this.ctx.storage.sql.databaseSize,
|
|
1477
|
-
connectedWakeSockets: this.ctx.getWebSockets().length,
|
|
1540
|
+
connectedWakeSockets: this.ctx.getWebSockets(WAKE_SUBSCRIBER_TAG).length,
|
|
1478
1541
|
upstreamAlarmAt,
|
|
1479
1542
|
writerEnabled: this.#writerEnabled(),
|
|
1480
1543
|
wasmMemoryBytes: engine_memory_bytes(),
|
|
@@ -1485,6 +1548,7 @@ export function createSyncDurableObject(config) {
|
|
|
1485
1548
|
sqlBillingSinceBoot: this.#sqlBilling,
|
|
1486
1549
|
counters: this.#counters,
|
|
1487
1550
|
ingestBreaker: this.#ingestBreaker.status(),
|
|
1551
|
+
lastWake: this.#lastWake,
|
|
1488
1552
|
}));
|
|
1489
1553
|
}
|
|
1490
1554
|
if (route === '/admin/sql') {
|
|
@@ -1609,8 +1673,20 @@ export function createSyncDurableObject(config) {
|
|
|
1609
1673
|
return json({ error: 'not found' }, 404);
|
|
1610
1674
|
}
|
|
1611
1675
|
async fetch(request) {
|
|
1612
|
-
this.#simulateIdleTeardown(Date.now());
|
|
1613
1676
|
const route = routeAfterNamespace(new URL(request.url).pathname);
|
|
1677
|
+
if (route === '/notify' &&
|
|
1678
|
+
request.method === 'POST' &&
|
|
1679
|
+
request.headers.get(NOTIFY_IF_SUBSCRIBED_HEADER) === '1' &&
|
|
1680
|
+
this.ctx.getWebSockets(WAKE_SUBSCRIBER_TAG).length === 0) {
|
|
1681
|
+
return json({
|
|
1682
|
+
ok: true,
|
|
1683
|
+
applied: 0,
|
|
1684
|
+
skipped: 'no-wake-subscribers',
|
|
1685
|
+
rowsWritten: this.#sqlBilling.rowsWritten,
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
this.#initialize();
|
|
1689
|
+
this.#simulateIdleTeardown(Date.now());
|
|
1614
1690
|
const namespace = request.headers.get(NAMESPACE_HEADER) ?? 'unknown';
|
|
1615
1691
|
const upstreamPath = this.#rememberUpstreamPath(request);
|
|
1616
1692
|
if (route.startsWith('/admin/'))
|
|
@@ -1622,7 +1698,7 @@ export function createSyncDurableObject(config) {
|
|
|
1622
1698
|
}
|
|
1623
1699
|
if (route === '/notify' && request.method === 'POST') {
|
|
1624
1700
|
try {
|
|
1625
|
-
const applied = await this.#
|
|
1701
|
+
const applied = await this.#ingestAfterCurrent(upstreamPath);
|
|
1626
1702
|
return json({ ok: true, applied });
|
|
1627
1703
|
}
|
|
1628
1704
|
catch (error) {
|
|
@@ -1652,8 +1728,9 @@ export function createSyncDurableObject(config) {
|
|
|
1652
1728
|
return json({ error: 'not found' }, 404);
|
|
1653
1729
|
}
|
|
1654
1730
|
async alarm() {
|
|
1655
|
-
if (this.ctx.getWebSockets().length === 0)
|
|
1731
|
+
if (this.ctx.getWebSockets(WAKE_SUBSCRIBER_TAG).length === 0)
|
|
1656
1732
|
return;
|
|
1733
|
+
this.#initialize();
|
|
1657
1734
|
try {
|
|
1658
1735
|
await this.#ingest();
|
|
1659
1736
|
}
|
|
@@ -1665,7 +1742,7 @@ export function createSyncDurableObject(config) {
|
|
|
1665
1742
|
}));
|
|
1666
1743
|
}
|
|
1667
1744
|
finally {
|
|
1668
|
-
if (this.ctx.getWebSockets().length > 0) {
|
|
1745
|
+
if (this.ctx.getWebSockets(WAKE_SUBSCRIBER_TAG).length > 0) {
|
|
1669
1746
|
const retryAfterMs = this.#ingestBreaker.status().retryAfterMs;
|
|
1670
1747
|
await this.ctx.storage.setAlarm(Date.now() + Math.max(upstreamIntervalMs, retryAfterMs));
|
|
1671
1748
|
}
|
|
@@ -1700,6 +1777,7 @@ export function createSyncDurableObject(config) {
|
|
|
1700
1777
|
socket.send('pong');
|
|
1701
1778
|
return;
|
|
1702
1779
|
}
|
|
1780
|
+
this.#initialize();
|
|
1703
1781
|
const host = this.#realtimeHost();
|
|
1704
1782
|
if (!host || typeof message !== 'string')
|
|
1705
1783
|
return;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { trackBillableCursorRows } from 'orez-sync-executor/sql-billing';
|
|
1
2
|
import { encodeSqlValue } from 'orez-sync-executor/sqlite';
|
|
2
3
|
import { executeTransactionQueryPlan } from './transaction-query.js';
|
|
3
|
-
import { trackBillableCursorRows } from './write-safeguards.js';
|
|
4
4
|
// Match transaction control only as the statement itself. CREATE TRIGGER uses
|
|
5
5
|
// `... BEGIN ... END` for its body, which is valid DO SQL and not a transaction.
|
|
6
6
|
const TX_SQL = /^\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)(?=\s|;|$)/i;
|
|
@@ -39,16 +39,5 @@ export declare class IngestCircuitBreaker {
|
|
|
39
39
|
restore(reason: IngestBreakerReason, retryAt: number, consecutiveTrips: number): void;
|
|
40
40
|
reopen(): void;
|
|
41
41
|
}
|
|
42
|
-
type BillableCursor = {
|
|
43
|
-
rowsRead?: number;
|
|
44
|
-
rowsWritten?: number;
|
|
45
|
-
next?: (...args: unknown[]) => unknown;
|
|
46
|
-
one?: (...args: unknown[]) => unknown;
|
|
47
|
-
toArray?: (...args: unknown[]) => unknown;
|
|
48
|
-
raw?: (...args: unknown[]) => unknown;
|
|
49
|
-
[Symbol.iterator]?: () => Iterator<unknown>;
|
|
50
|
-
};
|
|
51
|
-
export declare function trackBillableCursorRows<Cursor extends BillableCursor>(cursor: Cursor, recordWritten: (rows: number) => void, recordRead?: (rows: number) => void): Cursor;
|
|
52
42
|
export declare function retryDelayMs(attempt: number, initialBackoffMs: number, maxBackoffMs: number): number;
|
|
53
43
|
export declare function shouldRetryDelegatedPush(responseStatus: number | null, attempt: number, maxAttempts: number): boolean;
|
|
54
|
-
export {};
|
package/dist/write-safeguards.js
CHANGED
|
@@ -122,65 +122,6 @@ export class IngestCircuitBreaker {
|
|
|
122
122
|
this.recovered();
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
-
export function trackBillableCursorRows(cursor, recordWritten, recordRead = () => { }) {
|
|
126
|
-
if (!cursor || typeof cursor !== 'object')
|
|
127
|
-
return cursor;
|
|
128
|
-
let accountedWritten = 0;
|
|
129
|
-
let accountedRead = 0;
|
|
130
|
-
const account = () => {
|
|
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
|
-
}
|
|
141
|
-
};
|
|
142
|
-
const wrapIterator = (iterator) => {
|
|
143
|
-
const next = iterator.next.bind(iterator);
|
|
144
|
-
return {
|
|
145
|
-
next(...args) {
|
|
146
|
-
try {
|
|
147
|
-
return next(...args);
|
|
148
|
-
}
|
|
149
|
-
finally {
|
|
150
|
-
account();
|
|
151
|
-
}
|
|
152
|
-
},
|
|
153
|
-
[Symbol.iterator]() {
|
|
154
|
-
return this;
|
|
155
|
-
},
|
|
156
|
-
};
|
|
157
|
-
};
|
|
158
|
-
let proxy;
|
|
159
|
-
proxy = new Proxy(cursor, {
|
|
160
|
-
get(target, property) {
|
|
161
|
-
const value = Reflect.get(target, property, target);
|
|
162
|
-
if (property === Symbol.iterator)
|
|
163
|
-
return () => proxy;
|
|
164
|
-
if ((property === 'next' || property === 'one' || property === 'toArray') &&
|
|
165
|
-
typeof value === 'function') {
|
|
166
|
-
return (...args) => {
|
|
167
|
-
try {
|
|
168
|
-
return Reflect.apply(value, target, args);
|
|
169
|
-
}
|
|
170
|
-
finally {
|
|
171
|
-
account();
|
|
172
|
-
}
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
if (property === 'raw' && typeof value === 'function') {
|
|
176
|
-
return (...args) => wrapIterator(Reflect.apply(value, target, args));
|
|
177
|
-
}
|
|
178
|
-
return typeof value === 'function' ? value.bind(target) : value;
|
|
179
|
-
},
|
|
180
|
-
});
|
|
181
|
-
account();
|
|
182
|
-
return proxy;
|
|
183
|
-
}
|
|
184
125
|
export function retryDelayMs(attempt, initialBackoffMs, maxBackoffMs) {
|
|
185
126
|
return Math.min(maxBackoffMs, initialBackoffMs * 2 ** Math.max(0, attempt - 1));
|
|
186
127
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orez-sync-cf-host",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.16",
|
|
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.
|
|
61
|
+
"orez-sync-executor": "0.12.16"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@cloudflare/workers-types": "4.20260617.1",
|