@powersync/service-module-mongodb 0.0.0-dev-20260803144812 → 0.0.0-dev-20260929095716
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/CHANGELOG.md +95 -7
- package/ci/test-connection.yaml +12 -0
- package/dist/api/MongoRouteAPIAdapter.d.ts +2 -2
- package/dist/api/MongoRouteAPIAdapter.js +73 -143
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/api/infer-collection-schema.d.ts +12 -0
- package/dist/api/infer-collection-schema.js +142 -0
- package/dist/api/infer-collection-schema.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/module/MongoModule.d.ts +1 -2
- package/dist/module/MongoModule.js +2 -2
- package/dist/module/MongoModule.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +18 -2
- package/dist/replication/ChangeStream.js +275 -307
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
- package/dist/replication/ChangeStreamReplicationJob.js +6 -2
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/ChangeStreamReplicator.d.ts +3 -0
- package/dist/replication/ChangeStreamReplicator.js +3 -0
- package/dist/replication/ChangeStreamReplicator.js.map +1 -1
- package/dist/replication/MongoRelation.js +5 -2
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoReplicationQueryProvider.d.ts +87 -0
- package/dist/replication/MongoReplicationQueryProvider.js +23 -0
- package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
- package/dist/replication/MongoReplicationStream.d.ts +85 -0
- package/dist/replication/MongoReplicationStream.js +101 -0
- package/dist/replication/MongoReplicationStream.js.map +1 -0
- package/dist/replication/MongoSnapshotQuery.d.ts +2 -0
- package/dist/replication/MongoSnapshotQuery.js +12 -2
- package/dist/replication/MongoSnapshotQuery.js.map +1 -1
- package/dist/replication/MongoSnapshotter.d.ts +7 -2
- package/dist/replication/MongoSnapshotter.js +64 -91
- package/dist/replication/MongoSnapshotter.js.map +1 -1
- package/dist/replication/RawChangeStream.d.ts +4 -12
- package/dist/replication/RawChangeStream.js +1 -18
- package/dist/replication/RawChangeStream.js.map +1 -1
- package/dist/replication/SourceRowConverter.d.ts +1 -0
- package/dist/replication/SourceRowConverter.js +4 -2
- package/dist/replication/SourceRowConverter.js.map +1 -1
- package/dist/replication/bufferToSqlite.d.ts +3 -1
- package/dist/replication/bufferToSqlite.js +35 -23
- package/dist/replication/bufferToSqlite.js.map +1 -1
- package/dist/replication/checkpoints/CheckpointImplementation.d.ts +4 -1
- package/dist/replication/checkpoints/SentinelCheckpointImplementation.d.ts +4 -1
- package/dist/replication/checkpoints/SentinelCheckpointImplementation.js +1 -1
- package/dist/replication/checkpoints/SentinelCheckpointImplementation.js.map +1 -1
- package/dist/replication/checkpoints/TimestampCheckpointImplementation.d.ts +4 -1
- package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +2 -1
- package/dist/replication/checkpoints/TimestampCheckpointImplementation.js.map +1 -1
- package/dist/replication/replication-index.d.ts +6 -0
- package/dist/replication/replication-index.js +6 -0
- package/dist/replication/replication-index.js.map +1 -1
- package/dist/test-utils/ChangeStreamTestContext.d.ts +131 -0
- package/dist/test-utils/ChangeStreamTestContext.js +391 -0
- package/dist/test-utils/ChangeStreamTestContext.js.map +1 -0
- package/dist/test-utils/test-utils-index.d.ts +1 -0
- package/dist/test-utils/test-utils-index.js +2 -0
- package/dist/test-utils/test-utils-index.js.map +1 -0
- package/dist/types/types.d.ts +5 -0
- package/dist/types/types.js +20 -2
- package/dist/types/types.js.map +1 -1
- package/package.json +9 -9
- package/src/api/MongoRouteAPIAdapter.ts +79 -127
- package/src/api/infer-collection-schema.ts +153 -0
- package/src/index.ts +1 -0
- package/src/module/MongoModule.ts +3 -4
- package/src/replication/ChangeStream.ts +303 -350
- package/src/replication/ChangeStreamReplicationJob.ts +9 -3
- package/src/replication/ChangeStreamReplicator.ts +5 -0
- package/src/replication/MongoRelation.ts +8 -1
- package/src/replication/MongoReplicationQueryProvider.ts +108 -0
- package/src/replication/MongoReplicationStream.ts +201 -0
- package/src/replication/MongoSnapshotQuery.ts +19 -3
- package/src/replication/MongoSnapshotter.ts +76 -98
- package/src/replication/RawChangeStream.ts +5 -32
- package/src/replication/SourceRowConverter.ts +4 -2
- package/src/replication/bufferToSqlite.ts +43 -23
- package/src/replication/checkpoints/CheckpointImplementation.ts +1 -1
- package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +2 -2
- package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +3 -2
- package/src/replication/replication-index.ts +6 -0
- package/src/test-utils/ChangeStreamTestContext.ts +520 -0
- package/src/test-utils/test-utils-index.ts +1 -0
- package/src/types/types.ts +27 -2
- package/test/src/buffer_to_sqlite.test.ts +9 -0
- package/test/src/change_stream.test.ts +76 -3
- package/test/src/change_stream_test_setup.ts +38 -0
- package/test/src/chunked_snapshot.test.ts +3 -3
- package/test/src/config.test.ts +34 -0
- package/test/src/documentdb_mode.test.ts +4 -5
- package/test/src/raw_change_stream.test.ts +0 -63
- package/test/src/replication_stream.test.ts +311 -0
- package/test/src/resume.test.ts +10 -4
- package/test/src/resuming_snapshots.test.ts +3 -3
- package/test/src/schema.test.ts +223 -0
- package/test/src/slow_tests.test.ts +3 -3
- package/test/src/snapshot_query_provider.test.ts +95 -0
- package/test/src/stream_progress.test.ts +312 -0
- package/test/src/util.ts +11 -29
- package/tsconfig.tsbuildinfo +1 -1
- package/test/src/change_stream_utils.ts +0 -375
|
@@ -7,8 +7,9 @@ import { PostImagesOption } from '../types/types.js';
|
|
|
7
7
|
import { escapeRegExp } from '../utils.js';
|
|
8
8
|
import { createCheckpointImplementation } from './checkpoints/create-checkpoint-implementation.js';
|
|
9
9
|
import { getCacheIdentifier, getMongoRelation } from './MongoRelation.js';
|
|
10
|
+
import { DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER } from './MongoReplicationQueryProvider.js';
|
|
11
|
+
import { openMongoReplicationStream } from './MongoReplicationStream.js';
|
|
10
12
|
import { MongoSnapshotter } from './MongoSnapshotter.js';
|
|
11
|
-
import { parseChangeDocument, rawChangeStream } from './RawChangeStream.js';
|
|
12
13
|
import { CHECKPOINTS_COLLECTION, detectDocumentDb, timestampToDate } from './replication-utils.js';
|
|
13
14
|
import { DirectSourceRowConverter } from './SourceRowConverter.js';
|
|
14
15
|
/**
|
|
@@ -49,23 +50,43 @@ export class ChangeStream {
|
|
|
49
50
|
changeStreamTimeout;
|
|
50
51
|
storageHooks;
|
|
51
52
|
sourceRowConverter;
|
|
53
|
+
queryProvider;
|
|
52
54
|
keepaliveIntervalMs;
|
|
53
55
|
isDocumentDb = false;
|
|
54
56
|
_checkpointImplementation = null;
|
|
57
|
+
/**
|
|
58
|
+
* Last persisted resume timestamp as Date.now(). This represents the equivalent timestamp from the
|
|
59
|
+
* source database, rather than the time when we persisted it.
|
|
60
|
+
*/
|
|
61
|
+
lastPersistedResumeTimestamp = 0;
|
|
62
|
+
lastBatchCheckpoint = 0;
|
|
55
63
|
constructor(options) {
|
|
56
64
|
this.storage = options.storage;
|
|
57
65
|
this.metrics = options.metrics;
|
|
58
66
|
this.group_id = options.storage.replicationStreamId;
|
|
59
67
|
this.connections = options.connections;
|
|
60
|
-
this.maxAwaitTimeMS = options.maxAwaitTimeMS ?? 10_000;
|
|
61
68
|
this.snapshotChunkLength = options.snapshotChunkLength ?? 6_000;
|
|
62
69
|
this.keepaliveIntervalMs = options.keepaliveIntervalMs ?? 60_000;
|
|
70
|
+
if (options.maxAwaitTimeMS) {
|
|
71
|
+
this.maxAwaitTimeMS = options.maxAwaitTimeMS;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
// Default to 10s, unless keepaliveIntervalMs is shorter. In that case, use keepaliveIntervalMs,
|
|
75
|
+
// but add a bit of a margin, so that our keepalive logic is triggered on each empty change stream batch.
|
|
76
|
+
this.maxAwaitTimeMS = Math.min(10_000, this.keepaliveIntervalMs + 100);
|
|
77
|
+
}
|
|
63
78
|
this.storageHooks = options.storageHooks;
|
|
64
79
|
this.client = this.connections.client;
|
|
65
80
|
this.defaultDb = this.connections.db;
|
|
66
81
|
this.sync_rules = options.storage.getParsedSyncRules({
|
|
67
82
|
defaultSchema: this.defaultDb.databaseName
|
|
68
83
|
});
|
|
84
|
+
this.queryProvider =
|
|
85
|
+
options.createReplicationQueryProvider?.({
|
|
86
|
+
syncConfig: this.sync_rules,
|
|
87
|
+
connectionTag: this.connections.connectionTag,
|
|
88
|
+
defaultSchema: this.defaultDb.databaseName
|
|
89
|
+
}) ?? DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER;
|
|
69
90
|
this.sourceRowConverter = new DirectSourceRowConverter(this.sync_rules.compatibility);
|
|
70
91
|
// The change stream aggregation command should timeout before the socket times out,
|
|
71
92
|
// so we use 90% of the socket timeout value.
|
|
@@ -79,7 +100,8 @@ export class ChangeStream {
|
|
|
79
100
|
...options,
|
|
80
101
|
abortSignal: this.abortSignal,
|
|
81
102
|
logger: snapshotLogger,
|
|
82
|
-
checkpointStreamId: this.checkpointStreamId
|
|
103
|
+
checkpointStreamId: this.checkpointStreamId,
|
|
104
|
+
queryProvider: this.queryProvider
|
|
83
105
|
});
|
|
84
106
|
options.abort_signal.addEventListener('abort', () => {
|
|
85
107
|
this.abortController.abort(options.abort_signal.reason);
|
|
@@ -97,7 +119,9 @@ export class ChangeStream {
|
|
|
97
119
|
get configurePostImages() {
|
|
98
120
|
return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
|
|
99
121
|
}
|
|
100
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* The active checkpoint strategy. Only valid after ensureDetected().
|
|
124
|
+
*/
|
|
101
125
|
get checkpointImplementation() {
|
|
102
126
|
if (this._checkpointImplementation == null) {
|
|
103
127
|
throw new ReplicationAssertionError('Checkpoint implementation not initialized - call ensureDetected() first');
|
|
@@ -115,8 +139,9 @@ export class ChangeStream {
|
|
|
115
139
|
}
|
|
116
140
|
this.isDocumentDb = await detectDocumentDb(this.defaultDb);
|
|
117
141
|
if (this.isDocumentDb) {
|
|
118
|
-
this.logger.warn('Azure DocumentDB support is
|
|
142
|
+
this.logger.warn('Azure DocumentDB support is in alpha. APIs and behavior may change, and long-term stability is not yet guaranteed.');
|
|
119
143
|
}
|
|
144
|
+
await this.queryProvider.validateSource?.({ connectionManager: this.connections, isDocumentDb: this.isDocumentDb });
|
|
120
145
|
this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
|
|
121
146
|
client: this.client,
|
|
122
147
|
db: this.defaultDb,
|
|
@@ -377,89 +402,40 @@ export class ChangeStream {
|
|
|
377
402
|
catch (e) {
|
|
378
403
|
if (e instanceof mongo.MongoServerError &&
|
|
379
404
|
e.codeName == 'NoMatchingDocument' &&
|
|
380
|
-
e.errmsg?.includes('post-image was not found')) {
|
|
405
|
+
(e.errmsg?.includes('post-image was not found') || e.errmsg?.includes('pre-image was not found'))) {
|
|
381
406
|
throw new ChangeStreamInvalidatedError(e.errmsg, e);
|
|
382
407
|
}
|
|
383
408
|
throw e;
|
|
384
409
|
}
|
|
385
410
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
fullDocument = 'updateLookup';
|
|
404
|
-
}
|
|
405
|
-
const streamOptions = {
|
|
406
|
-
fullDocument: fullDocument
|
|
407
|
-
};
|
|
408
|
-
if (!this.isDocumentDb) {
|
|
409
|
-
// DocumentDB does not support showExpandedEvents.
|
|
410
|
-
streamOptions.showExpandedEvents = true;
|
|
411
|
-
}
|
|
412
|
-
const pipeline = [
|
|
413
|
-
{
|
|
414
|
-
$changeStream: streamOptions
|
|
415
|
-
},
|
|
416
|
-
{
|
|
417
|
-
$match: filters.$match
|
|
411
|
+
openChangeStream(options) {
|
|
412
|
+
return openMongoReplicationStream({
|
|
413
|
+
db: this.defaultDb,
|
|
414
|
+
queryProvider: this.queryProvider,
|
|
415
|
+
namespaceFilter: options.filters,
|
|
416
|
+
isDocumentDb: this.isDocumentDb,
|
|
417
|
+
usePostImages: this.usePostImages,
|
|
418
|
+
position: options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null,
|
|
419
|
+
skipInitialTimestamp: true,
|
|
420
|
+
onBatch: options.onBatch,
|
|
421
|
+
options: {
|
|
422
|
+
batchSize: options.batchSize ?? this.snapshotChunkLength,
|
|
423
|
+
maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
|
|
424
|
+
maxTimeMS: this.changeStreamTimeout,
|
|
425
|
+
signal: options.signal,
|
|
426
|
+
logger: this.logger,
|
|
427
|
+
tracer: options.tracer
|
|
418
428
|
}
|
|
419
|
-
];
|
|
420
|
-
if (!this.isDocumentDb) {
|
|
421
|
-
// DocumentDB does not support $changeStreamSplitLargeEvent.
|
|
422
|
-
pipeline.push({ $changeStreamSplitLargeEvent: {} });
|
|
423
|
-
}
|
|
424
|
-
/**
|
|
425
|
-
* Only one of these options can be supplied at a time.
|
|
426
|
-
*/
|
|
427
|
-
if (resumeAfter) {
|
|
428
|
-
streamOptions.resumeAfter = resumeAfter;
|
|
429
|
-
}
|
|
430
|
-
else if (startAfter != null) {
|
|
431
|
-
// Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
|
|
432
|
-
// case if we have an old one.
|
|
433
|
-
// This is also relevant for getSnapshotLSN().
|
|
434
|
-
// The sentinel implementation never produces a startAfter, and a fresh DocumentDB stream
|
|
435
|
-
// opens from "now" with neither option set.
|
|
436
|
-
streamOptions.startAtOperationTime = startAfter;
|
|
437
|
-
}
|
|
438
|
-
let watchDb;
|
|
439
|
-
if (this.isDocumentDb || filters.multipleDatabases) {
|
|
440
|
-
// DocumentDB only supports cluster-level change streams.
|
|
441
|
-
watchDb = this.client.db('admin');
|
|
442
|
-
streamOptions.allChangesForCluster = true;
|
|
443
|
-
}
|
|
444
|
-
else {
|
|
445
|
-
watchDb = this.defaultDb;
|
|
446
|
-
}
|
|
447
|
-
const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
|
|
448
|
-
return rawChangeStream(watchDb, pipeline, {
|
|
449
|
-
batchSize: options.batchSize ?? this.snapshotChunkLength,
|
|
450
|
-
maxAwaitTimeMS,
|
|
451
|
-
// maxAwaitTimeMS can be 0 for probe-style streams that do not want an idle wait.
|
|
452
|
-
// In that case there is no client-side wait to emulate for DocumentDB.
|
|
453
|
-
clientSideMaxAwaitTimeMS: this.isDocumentDb && maxAwaitTimeMS > 0,
|
|
454
|
-
maxTimeMS: this.changeStreamTimeout,
|
|
455
|
-
signal: options.signal,
|
|
456
|
-
logger: this.logger,
|
|
457
|
-
tracer: options.tracer
|
|
458
429
|
});
|
|
459
430
|
}
|
|
460
431
|
rawToSqliteRow(row) {
|
|
461
432
|
return this.sourceRowConverter.rawToSqliteRow(row);
|
|
462
433
|
}
|
|
434
|
+
async createBatchCheckpoint() {
|
|
435
|
+
const checkpoint = await this.checkpointImplementation.createBatchCheckpoint();
|
|
436
|
+
this.lastBatchCheckpoint = Date.now();
|
|
437
|
+
return checkpoint;
|
|
438
|
+
}
|
|
463
439
|
async streamChangesInternal() {
|
|
464
440
|
await this.ensureDetected();
|
|
465
441
|
const transactionsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.TRANSACTIONS_REPLICATED);
|
|
@@ -473,276 +449,245 @@ export class ChangeStream {
|
|
|
473
449
|
// We get a complete postimage for every change, so we don't need to store the current data.
|
|
474
450
|
storeCurrentData: false,
|
|
475
451
|
hooks: this.storageHooks,
|
|
452
|
+
signal: this.abortSignal,
|
|
476
453
|
tracer
|
|
477
454
|
}, async (batch) => {
|
|
478
455
|
const { resumeFromLsn } = batch;
|
|
479
456
|
if (resumeFromLsn == null) {
|
|
480
457
|
throw new ReplicationAssertionError(`No LSN found to resume from`);
|
|
481
458
|
}
|
|
482
|
-
// Seed the
|
|
483
|
-
// the legacy startAfter timestamp (timestamp implementation only) for the
|
|
484
|
-
// resume-boundary dedupe guard below.
|
|
459
|
+
// Seed the checkpoint strategy's coordinate from the durable source position.
|
|
485
460
|
this.checkpointImplementation.seedPosition(resumeFromLsn);
|
|
486
|
-
const { startAfter } = this.checkpointImplementation.parseResumePosition(resumeFromLsn);
|
|
487
461
|
let outerSpan = tracer.span('batch');
|
|
488
462
|
this.checkpointImplementation.logResume(resumeFromLsn);
|
|
489
463
|
const filters = this.getSourceNamespaceFilters();
|
|
490
464
|
// This is closed when the for loop below returns/breaks/throws
|
|
491
|
-
|
|
465
|
+
let processingSpan;
|
|
466
|
+
let receivedBytes = 0;
|
|
467
|
+
let changesSinceProgress = 0;
|
|
468
|
+
const stream = this.openChangeStream({
|
|
492
469
|
lsn: resumeFromLsn,
|
|
493
470
|
filters,
|
|
494
471
|
signal: this.abortSignal,
|
|
495
|
-
tracer
|
|
472
|
+
tracer,
|
|
473
|
+
onBatch: (batch) => {
|
|
474
|
+
// Count actual transport, even if the adapter drops every envelope or reassembles fragments.
|
|
475
|
+
bytesReplicatedMetric.add(batch.byteSize);
|
|
476
|
+
chunksReplicatedMetric.add(1);
|
|
477
|
+
receivedBytes += batch.byteSize;
|
|
478
|
+
processingSpan = tracer.span('processing');
|
|
479
|
+
return processingSpan;
|
|
480
|
+
}
|
|
496
481
|
});
|
|
497
482
|
// Always start with a checkpoint.
|
|
498
483
|
// This helps us to clear errors when restarting, even if there is
|
|
499
484
|
// no data to replicate.
|
|
500
|
-
let waitForCheckpointLsn = await this.
|
|
501
|
-
let
|
|
502
|
-
let flexDbNameWorkaroundLogged = false;
|
|
503
|
-
let lastEmptyResume = performance.now();
|
|
485
|
+
let waitForCheckpointLsn = await this.createBatchCheckpoint();
|
|
486
|
+
let lastKeepalive = performance.now();
|
|
504
487
|
let lastTxnKey = null;
|
|
505
|
-
for await (
|
|
506
|
-
|
|
507
|
-
using batchSpan = tracer.span('processing');
|
|
508
|
-
bytesReplicatedMetric.add(eventBatch.byteSize);
|
|
509
|
-
chunksReplicatedMetric.add(1);
|
|
510
|
-
if (this.abortSignal.aborted) {
|
|
488
|
+
for await (const item of stream) {
|
|
489
|
+
if (this.abortSignal.aborted)
|
|
511
490
|
break;
|
|
512
|
-
}
|
|
513
491
|
this.touch();
|
|
514
|
-
if (
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
492
|
+
if (item.type == 'progress') {
|
|
493
|
+
const { resumeToken, filteredCount } = item;
|
|
494
|
+
if (changesSinceProgress == 0) {
|
|
495
|
+
// No retained changes since the previous progress item: this is either idle or filtered-only traffic.
|
|
496
|
+
// Case 1: No pending barrier and the keepalive interval has elapsed. Advance checkpoints as below,
|
|
497
|
+
// then flush and save the resume token.
|
|
498
|
+
// Case 2: Idle traffic with a recent keepalive. Skip this resume update, whether or not a barrier
|
|
499
|
+
// is pending, to preserve the idle throttle.
|
|
500
|
+
// Case 3: All other combinations. Flush and save the resume token without requesting a checkpoint.
|
|
501
|
+
// A pending barrier will provide the checkpoint boundary once replication reaches it. Filtered
|
|
502
|
+
// progress still saves its token even during the keepalive interval, so excluded work is resumable.
|
|
503
|
+
const hadRecentKeepalive = performance.now() - lastKeepalive < this.keepaliveIntervalMs;
|
|
504
|
+
if (waitForCheckpointLsn == null && !hadRecentKeepalive) {
|
|
505
|
+
if (filteredCount > 0) {
|
|
506
|
+
// Case 1a: A transaction can span batches: this batch may contain only filtered changes,
|
|
507
|
+
// while retained changes from the same transaction are still unread. They share a timestamp,
|
|
508
|
+
// so publishing a checkpoint here could acknowledge the transaction before its data is saved.
|
|
509
|
+
// Request a source barrier; reaching it ensures those retained changes have been processed.
|
|
510
|
+
// The progress token can still be saved below for recovery without publishing a checkpoint.
|
|
511
|
+
using _ = tracer.span('source_checkpoint');
|
|
512
|
+
waitForCheckpointLsn = await this.createBatchCheckpoint();
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
// Case 1b: Idle traffic. The timestamp implementation persists a keepalive directly;
|
|
516
|
+
// the sentinel implementation writes a source marker whose event will commit later.
|
|
517
|
+
await this.checkpointImplementation.keepalive(batch, resumeToken);
|
|
518
|
+
this.replicationLag.markStarted();
|
|
519
|
+
}
|
|
520
|
+
this.touch();
|
|
521
|
+
lastKeepalive = performance.now();
|
|
522
|
+
}
|
|
523
|
+
else if (filteredCount == 0 && hadRecentKeepalive) {
|
|
524
|
+
// Case 2: Only idle progress skips resume persistence. Cases 1 and 3 fall through below.
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
532
527
|
}
|
|
528
|
+
const { lsn, timestamp } = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
|
|
529
|
+
// Row writes must be durable before their source token. This advances recovery without
|
|
530
|
+
// publishing a checkpoint ahead of an outstanding barrier, transaction or snapshot.
|
|
531
|
+
await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
|
|
532
|
+
await batch.setResumeLsn(lsn);
|
|
533
|
+
// MongoDB's token timestamp may lag by about 10 seconds. DocumentDB tokens have no timestamp.
|
|
534
|
+
this.lastPersistedResumeTimestamp = timestamp?.getTime() ?? Date.now();
|
|
535
|
+
processingSpan?.end();
|
|
536
|
+
const durationsMicroseconds = outerSpan.end();
|
|
537
|
+
this.logger.info(`Processed ${changesSinceProgress} changes and ${filteredCount} filtered events`, {
|
|
538
|
+
count: changesSinceProgress,
|
|
539
|
+
filteredCount,
|
|
540
|
+
bytes: receivedBytes,
|
|
541
|
+
duration: processingSpan?.durationMillis,
|
|
542
|
+
t: durationsMicroseconds
|
|
543
|
+
});
|
|
544
|
+
changesSinceProgress = 0;
|
|
545
|
+
receivedBytes = 0;
|
|
546
|
+
outerSpan = tracer.span('batch');
|
|
547
|
+
continue;
|
|
533
548
|
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
549
|
+
// The shared reader has already reassembled complete events and normalized their namespaces.
|
|
550
|
+
const changeDocument = item.event;
|
|
551
|
+
changesSinceProgress++;
|
|
552
|
+
const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
|
|
553
|
+
if (ns?.coll == CHECKPOINTS_COLLECTION) {
|
|
554
|
+
/**
|
|
555
|
+
* Dropping the database does not provide an `invalidate` event.
|
|
556
|
+
* We typically would receive `drop` events for the collection which we
|
|
557
|
+
* would process below.
|
|
558
|
+
*
|
|
559
|
+
* However we don't commit the LSN after collections are dropped.
|
|
560
|
+
* This prevents the `startAfter` or `resumeToken` from advancing past the drop events.
|
|
561
|
+
* The stream also closes after the drop events.
|
|
562
|
+
* This causes an infinite loop of processing the collection drop events.
|
|
563
|
+
*
|
|
564
|
+
* This check here invalidates the change stream if our `_powersync_checkpoints` collection
|
|
565
|
+
* is dropped. This allows for detecting when the DB is dropped.
|
|
566
|
+
*/
|
|
567
|
+
if (changeDocument.operationType == 'drop') {
|
|
568
|
+
throw new ChangeStreamInvalidatedError('Internal collections have been dropped', new Error('_powersync_checkpoints collection was dropped'));
|
|
540
569
|
}
|
|
541
|
-
if (
|
|
570
|
+
if (!(changeDocument.operationType == 'insert' ||
|
|
571
|
+
changeDocument.operationType == 'update' ||
|
|
572
|
+
changeDocument.operationType == 'replace')) {
|
|
542
573
|
continue;
|
|
543
574
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
575
|
+
// We handle two types of checkpoint events:
|
|
576
|
+
// 1. "Standalone" checkpoints, typically write checkpoints. We want to process these
|
|
577
|
+
// immediately, regardless of where they were created.
|
|
578
|
+
// 2. "Batch" checkpoints for the current stream. This is used as a form of dynamic rate
|
|
579
|
+
// limiting of commits, so we specifically want to exclude checkpoints from other streams.
|
|
580
|
+
//
|
|
581
|
+
// It may be useful to also throttle commits due to standalone checkpoints in the future.
|
|
582
|
+
// However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
|
|
583
|
+
const kind = this.checkpointImplementation.event.observe(changeDocument);
|
|
584
|
+
if (kind == 'foreign') {
|
|
585
|
+
// Another stream's barrier - ignore.
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
else if (kind == 'standalone') {
|
|
589
|
+
// Standalone / write checkpoint received.
|
|
590
|
+
// When we are caught up, commit immediately to keep write checkpoint latency low.
|
|
591
|
+
// Once there is already a batch checkpoint pending, or the driver has buffered more
|
|
592
|
+
// change stream events, collapse standalone checkpoints into the normal batch
|
|
593
|
+
// checkpoint flow to avoid commit churn under sustained load.
|
|
594
|
+
const hasBufferedChanges = item.hasBufferedChanges;
|
|
595
|
+
if (hasBufferedChanges && waitForCheckpointLsn == null) {
|
|
596
|
+
// Buffered changes - create a new batch checkpoint to rate limit commits
|
|
597
|
+
using _ = tracer.span('source_checkpoint');
|
|
598
|
+
waitForCheckpointLsn = await this.createBatchCheckpoint();
|
|
599
|
+
continue;
|
|
554
600
|
}
|
|
555
|
-
if (
|
|
556
|
-
//
|
|
557
|
-
|
|
558
|
-
splitDocument = null;
|
|
601
|
+
else if (waitForCheckpointLsn != null) {
|
|
602
|
+
// Skip this checkpoint - wait for the batch checkpoint.
|
|
603
|
+
continue;
|
|
559
604
|
}
|
|
560
605
|
else {
|
|
561
|
-
//
|
|
562
|
-
continue;
|
|
606
|
+
// No buffered changes, and no batch checkpoint pending - commit immediately.
|
|
563
607
|
}
|
|
564
608
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
609
|
+
// kind == 'own-barrier' falls through to commit.
|
|
610
|
+
const lsn = this.checkpointImplementation.event.lsn(changeDocument);
|
|
611
|
+
if (waitForCheckpointLsn != null &&
|
|
612
|
+
this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)) {
|
|
613
|
+
waitForCheckpointLsn = null;
|
|
568
614
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
// 1. We're replicating from an Atlas Flex instance.
|
|
575
|
-
// 2. There were changestream events recorded while the PowerSync service is paused.
|
|
576
|
-
// 3. We're only replicating from a single database.
|
|
577
|
-
// Then we've observed an ns with for example {db: '67b83e86cd20730f1e766dde_ps'},
|
|
578
|
-
// instead of the expected {db: 'ps'}.
|
|
579
|
-
// We correct this.
|
|
580
|
-
changeDocument.ns.db = this.defaultDb.databaseName;
|
|
581
|
-
if (!flexDbNameWorkaroundLogged) {
|
|
582
|
-
flexDbNameWorkaroundLogged = true;
|
|
583
|
-
this.logger.warn(`Incorrect DB name in change stream: ${changeDocument.ns.db}. Changed to ${this.defaultDb.databaseName}.`);
|
|
584
|
-
}
|
|
615
|
+
const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
|
|
616
|
+
oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
|
|
617
|
+
});
|
|
618
|
+
if (!checkpointBlocked || checkpointCreated) {
|
|
619
|
+
this.replicationLag.markCommitted();
|
|
585
620
|
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
* This prevents the `startAfter` or `resumeToken` from advancing past the drop events.
|
|
595
|
-
* The stream also closes after the drop events.
|
|
596
|
-
* This causes an infinite loop of processing the collection drop events.
|
|
597
|
-
*
|
|
598
|
-
* This check here invalidates the change stream if our `_powersync_checkpoints` collection
|
|
599
|
-
* is dropped. This allows for detecting when the DB is dropped.
|
|
600
|
-
*/
|
|
601
|
-
if (changeDocument.operationType == 'drop') {
|
|
602
|
-
throw new ChangeStreamInvalidatedError('Internal collections have been dropped', new Error('_powersync_checkpoints collection was dropped'));
|
|
603
|
-
}
|
|
604
|
-
if (!(changeDocument.operationType == 'insert' ||
|
|
605
|
-
changeDocument.operationType == 'update' ||
|
|
606
|
-
changeDocument.operationType == 'replace')) {
|
|
607
|
-
continue;
|
|
608
|
-
}
|
|
609
|
-
// We handle two types of checkpoint events:
|
|
610
|
-
// 1. "Standalone" checkpoints, typically write checkpoints. We want to process these
|
|
611
|
-
// immediately, regardless of where they were created.
|
|
612
|
-
// 2. "Batch" checkpoints for the current stream. This is used as a form of dynamic rate
|
|
613
|
-
// limiting of commits, so we specifically want to exclude checkpoints from other streams.
|
|
614
|
-
//
|
|
615
|
-
// It may be useful to also throttle commits due to standalone checkpoints in the future.
|
|
616
|
-
// However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
|
|
617
|
-
const kind = this.checkpointImplementation.event.observe(changeDocument);
|
|
618
|
-
if (kind == 'foreign') {
|
|
619
|
-
// Another stream's barrier - ignore.
|
|
620
|
-
continue;
|
|
621
|
-
}
|
|
622
|
-
else if (kind == 'standalone') {
|
|
623
|
-
// Standalone / write checkpoint received.
|
|
624
|
-
// When we are caught up, commit immediately to keep write checkpoint latency low.
|
|
625
|
-
// Once there is already a batch checkpoint pending, or the driver has buffered more
|
|
626
|
-
// change stream events, collapse standalone checkpoints into the normal batch
|
|
627
|
-
// checkpoint flow to avoid commit churn under sustained load.
|
|
628
|
-
const hasBufferedChanges = eventIndex < events.length - 1;
|
|
629
|
-
if (hasBufferedChanges && waitForCheckpointLsn == null) {
|
|
630
|
-
// Buffered changes - create a new batch checkpoint to rate limit commits
|
|
631
|
-
using _ = tracer.span('source_checkpoint');
|
|
632
|
-
waitForCheckpointLsn = await this.checkpointImplementation.createBatchCheckpoint();
|
|
633
|
-
continue;
|
|
634
|
-
}
|
|
635
|
-
else if (waitForCheckpointLsn != null) {
|
|
636
|
-
// Skip this checkpoint - wait for the batch checkpoint.
|
|
637
|
-
continue;
|
|
638
|
-
}
|
|
639
|
-
else {
|
|
640
|
-
// No buffered changes, and no batch checkpoint pending - commit immediately.
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
// kind == 'own-barrier' falls through to commit.
|
|
644
|
-
const lsn = this.checkpointImplementation.event.lsn(changeDocument);
|
|
645
|
-
if (waitForCheckpointLsn != null &&
|
|
646
|
-
this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)) {
|
|
647
|
-
waitForCheckpointLsn = null;
|
|
648
|
-
}
|
|
649
|
-
const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
|
|
650
|
-
oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
|
|
651
|
-
});
|
|
652
|
-
if (!checkpointBlocked || checkpointCreated) {
|
|
653
|
-
this.replicationLag.markCommitted();
|
|
654
|
-
}
|
|
621
|
+
}
|
|
622
|
+
else if (changeDocument.operationType == 'insert' ||
|
|
623
|
+
changeDocument.operationType == 'update' ||
|
|
624
|
+
changeDocument.operationType == 'replace' ||
|
|
625
|
+
changeDocument.operationType == 'delete') {
|
|
626
|
+
if (waitForCheckpointLsn == null) {
|
|
627
|
+
using _ = tracer.span('source_checkpoint');
|
|
628
|
+
waitForCheckpointLsn = await this.createBatchCheckpoint();
|
|
655
629
|
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
630
|
+
const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
631
|
+
const tables = await this.getRelations(batch, rel, {
|
|
632
|
+
// In most cases, we should not need to snapshot this. But if this is the first time we see the collection
|
|
633
|
+
// for whatever reason, then we do need to snapshot it.
|
|
634
|
+
// This may result in some duplicate operations when a collection is created for the first time after
|
|
635
|
+
// sync config was deployed.
|
|
636
|
+
snapshot: true
|
|
637
|
+
});
|
|
638
|
+
const tablesToReplicate = tables.filter((table) => table.syncAny);
|
|
639
|
+
if (tablesToReplicate.length > 0) {
|
|
640
|
+
this.replicationLag.trackUncommittedChange(
|
|
641
|
+
// Standard MongoDB uses clusterTime, unchanged. DocumentDB has no
|
|
642
|
+
// clusterTime, so fall back to wallTime there for the lag metric.
|
|
643
|
+
changeDocument.clusterTime != null
|
|
644
|
+
? timestampToDate(changeDocument.clusterTime)
|
|
645
|
+
: (changeDocument.wallTime ?? null));
|
|
646
|
+
const transactionKeyValue = transactionKey(changeDocument);
|
|
647
|
+
if (transactionKeyValue == null || lastTxnKey != transactionKeyValue) {
|
|
648
|
+
// Very crude metric for counting transactions replicated.
|
|
649
|
+
// We ignore operations other than basic CRUD, and ignore changes to _powersync_checkpoints.
|
|
650
|
+
// Individual writes may not have a txnNumber, in which case we count them as separate transactions.
|
|
651
|
+
lastTxnKey = transactionKeyValue;
|
|
652
|
+
transactionsReplicatedMetric.add(1);
|
|
663
653
|
}
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
// In most cases, we should not need to snapshot this. But if this is the first time we see the collection
|
|
667
|
-
// for whatever reason, then we do need to snapshot it.
|
|
668
|
-
// This may result in some duplicate operations when a collection is created for the first time after
|
|
669
|
-
// sync config was deployed.
|
|
670
|
-
snapshot: true
|
|
671
|
-
});
|
|
672
|
-
const tablesToReplicate = tables.filter((table) => table.syncAny);
|
|
673
|
-
if (tablesToReplicate.length > 0) {
|
|
674
|
-
this.replicationLag.trackUncommittedChange(
|
|
675
|
-
// Standard MongoDB uses clusterTime, unchanged. DocumentDB has no
|
|
676
|
-
// clusterTime, so fall back to wallTime there for the lag metric.
|
|
677
|
-
changeDocument.clusterTime != null
|
|
678
|
-
? timestampToDate(changeDocument.clusterTime)
|
|
679
|
-
: (changeDocument.wallTime ?? null));
|
|
680
|
-
const transactionKeyValue = transactionKey(changeDocument);
|
|
681
|
-
if (transactionKeyValue == null || lastTxnKey != transactionKeyValue) {
|
|
682
|
-
// Very crude metric for counting transactions replicated.
|
|
683
|
-
// We ignore operations other than basic CRUD, and ignore changes to _powersync_checkpoints.
|
|
684
|
-
// Individual writes may not have a txnNumber, in which case we count them as separate transactions.
|
|
685
|
-
lastTxnKey = transactionKeyValue;
|
|
686
|
-
transactionsReplicatedMetric.add(1);
|
|
687
|
-
}
|
|
688
|
-
for (const table of tablesToReplicate) {
|
|
689
|
-
await this.writeChange(batch, table, changeDocument);
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
else if (changeDocument.operationType == 'drop') {
|
|
694
|
-
const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
695
|
-
const tables = await this.getRelations(batch, rel, {
|
|
696
|
-
// We're "dropping" this collection, so never snapshot it.
|
|
697
|
-
snapshot: false
|
|
698
|
-
});
|
|
699
|
-
const tablesToDrop = tables.filter((table) => table.syncAny);
|
|
700
|
-
if (tablesToDrop.length > 0) {
|
|
701
|
-
await batch.drop(tablesToDrop);
|
|
654
|
+
for (const table of tablesToReplicate) {
|
|
655
|
+
await this.writeChange(batch, table, changeDocument);
|
|
702
656
|
}
|
|
703
|
-
this.relationCache.delete(rel);
|
|
704
657
|
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
}
|
|
716
|
-
this.relationCache.delete(relFrom);
|
|
717
|
-
// Here we do need to snapshot the new table
|
|
718
|
-
const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
|
|
719
|
-
await this.handleRelation(batch, relTo, {
|
|
720
|
-
// This is a new (renamed) collection, so always snapshot it.
|
|
721
|
-
snapshot: true,
|
|
722
|
-
collectionInfo: collection
|
|
723
|
-
});
|
|
658
|
+
}
|
|
659
|
+
else if (changeDocument.operationType == 'drop') {
|
|
660
|
+
const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
661
|
+
const tables = await this.getRelations(batch, rel, {
|
|
662
|
+
// We're "dropping" this collection, so never snapshot it.
|
|
663
|
+
snapshot: false
|
|
664
|
+
});
|
|
665
|
+
const tablesToDrop = tables.filter((table) => table.syncAny);
|
|
666
|
+
if (tablesToDrop.length > 0) {
|
|
667
|
+
await batch.drop(tablesToDrop);
|
|
724
668
|
}
|
|
669
|
+
this.relationCache.delete(rel);
|
|
725
670
|
}
|
|
726
|
-
if (
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
671
|
+
else if (changeDocument.operationType == 'rename') {
|
|
672
|
+
const relFrom = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
|
|
673
|
+
const relTo = getMongoRelation(changeDocument.to, this.connections.connectionTag);
|
|
674
|
+
const tablesFrom = await this.getRelations(batch, relFrom, {
|
|
675
|
+
// We're "dropping" this collection, so never snapshot it.
|
|
676
|
+
snapshot: false
|
|
677
|
+
});
|
|
678
|
+
const tablesToDrop = tablesFrom.filter((table) => table.syncAny);
|
|
679
|
+
if (tablesToDrop.length > 0) {
|
|
680
|
+
await batch.drop(tablesToDrop);
|
|
681
|
+
}
|
|
682
|
+
this.relationCache.delete(relFrom);
|
|
683
|
+
// Here we do need to snapshot the new table
|
|
684
|
+
const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
|
|
685
|
+
await this.handleRelation(batch, relTo, {
|
|
686
|
+
// This is a new (renamed) collection, so always snapshot it.
|
|
687
|
+
snapshot: true,
|
|
688
|
+
collectionInfo: collection
|
|
689
|
+
});
|
|
735
690
|
}
|
|
736
|
-
batchSpan.end();
|
|
737
|
-
const durationsMicroseconds = outerSpan.end();
|
|
738
|
-
const duration = batchSpan.durationMillis;
|
|
739
|
-
this.logger.info(`Processed batch of ${events.length} changes / ${eventBatch.byteSize} bytes in ${duration}ms`, {
|
|
740
|
-
count: events.length,
|
|
741
|
-
bytes: eventBatch.byteSize,
|
|
742
|
-
duration,
|
|
743
|
-
t: durationsMicroseconds
|
|
744
|
-
});
|
|
745
|
-
outerSpan = tracer.span('batch');
|
|
746
691
|
}
|
|
747
692
|
});
|
|
748
693
|
throw new ReplicationAbortedError(`Replication stream aborted`, this.abortSignal.reason);
|
|
@@ -750,6 +695,29 @@ export class ChangeStream {
|
|
|
750
695
|
getReplicationLagMillis() {
|
|
751
696
|
return this.replicationLag.getLagMillis();
|
|
752
697
|
}
|
|
698
|
+
async keepAlive() {
|
|
699
|
+
// This is called on an interval of keepaliveIntervalMs.
|
|
700
|
+
// This writes to _powersync_checkpoints.
|
|
701
|
+
// Main use case: When there are massive bulk writes to another database or collection, the change stream may
|
|
702
|
+
// start timing out due to reading through too much data in one batch. This breaks up those bulk writes,
|
|
703
|
+
// allowing the change stream to make progress.
|
|
704
|
+
// When there is low replication traffic, regular calls to setResumeLsn() with recent resume tokens make this unnecessary.
|
|
705
|
+
// We track that using lastPersistedResumeTimestamp.
|
|
706
|
+
// We add a bit of a margin here, since setResumeLsn is not called on an exact interval.
|
|
707
|
+
const staleResumeLsn = Date.now() - this.lastPersistedResumeTimestamp > this.keepaliveIntervalMs * 1.1;
|
|
708
|
+
// When there is replication lag, that resumeToken can get too outdated. In that case, we periodically create a
|
|
709
|
+
// new back checkpoint. This interval is controlled by the frequency of the keepAlive() call,
|
|
710
|
+
// while also skipping if there was another call to createBatchCheckpoint().
|
|
711
|
+
// We use a factor of 0.9 here, to make sure this is called on every keepAlive() interval, unless there
|
|
712
|
+
// was another call to createBatchCheckpoint().
|
|
713
|
+
const staleBatchCheckpoint = Date.now() - this.lastBatchCheckpoint > this.keepaliveIntervalMs * 0.9;
|
|
714
|
+
// We don't use oldestUncommittedChange here, since that may be unset in some edge cases where we do need
|
|
715
|
+
// to persist new checkpoints.
|
|
716
|
+
if (staleResumeLsn && staleBatchCheckpoint) {
|
|
717
|
+
await this.ensureDetected();
|
|
718
|
+
await this.createBatchCheckpoint();
|
|
719
|
+
}
|
|
720
|
+
}
|
|
753
721
|
lastTouchedAt = performance.now();
|
|
754
722
|
touch() {
|
|
755
723
|
if (performance.now() - this.lastTouchedAt > 1_000) {
|