@powersync/service-module-mongodb 0.21.1 → 0.22.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/CHANGELOG.md +74 -0
- package/benchmarks/env.ts +11 -0
- package/benchmarks/replication_branch_throughput.benchmark.ts +240 -0
- package/ci/test-connection.yaml +12 -0
- package/dist/api/MongoRouteAPIAdapter.d.ts +6 -0
- package/dist/api/MongoRouteAPIAdapter.js +33 -4
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/api/infer-collection-schema.d.ts +5 -0
- package/dist/api/infer-collection-schema.js +24 -0
- package/dist/api/infer-collection-schema.js.map +1 -1
- 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 +1 -1
- package/dist/module/MongoModule.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +13 -3
- package/dist/replication/ChangeStream.js +130 -196
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
- package/dist/replication/ChangeStreamReplicationJob.js +19 -8
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/ChangeStreamReplicator.d.ts +4 -0
- package/dist/replication/ChangeStreamReplicator.js +6 -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 +85 -0
- package/dist/replication/MongoReplicationQueryProvider.js +23 -0
- package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
- package/dist/replication/MongoReplicationStream.d.ts +74 -0
- package/dist/replication/MongoReplicationStream.js +228 -0
- package/dist/replication/MongoReplicationStream.js.map +1 -0
- package/dist/replication/MongoReplicationStreamTypes.d.ts +157 -0
- package/dist/replication/MongoReplicationStreamTypes.js +19 -0
- package/dist/replication/MongoReplicationStreamTypes.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 +90 -88
- package/dist/replication/MongoSnapshotter.js.map +1 -1
- package/dist/replication/RawChangeStream.d.ts +1 -1
- package/dist/replication/RawChangeStream.js +1 -1
- 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/estimateFilteredSnapshotCount.d.ts +12 -0
- package/dist/replication/estimateFilteredSnapshotCount.js +79 -0
- package/dist/replication/estimateFilteredSnapshotCount.js.map +1 -0
- package/dist/replication/replication-index.d.ts +7 -0
- package/dist/replication/replication-index.js +7 -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 +390 -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/package.json +11 -10
- package/src/api/MongoRouteAPIAdapter.ts +38 -4
- package/src/api/infer-collection-schema.ts +26 -0
- package/src/index.ts +1 -0
- package/src/module/MongoModule.ts +2 -3
- package/src/replication/ChangeStream.ts +143 -226
- package/src/replication/ChangeStreamReplicationJob.ts +21 -9
- package/src/replication/ChangeStreamReplicator.ts +9 -0
- package/src/replication/MongoRelation.ts +8 -1
- package/src/replication/MongoReplicationQueryProvider.ts +106 -0
- package/src/replication/MongoReplicationStream.ts +314 -0
- package/src/replication/MongoReplicationStreamTypes.ts +169 -0
- package/src/replication/MongoSnapshotQuery.ts +19 -3
- package/src/replication/MongoSnapshotter.ts +101 -92
- package/src/replication/RawChangeStream.ts +2 -2
- package/src/replication/SourceRowConverter.ts +4 -2
- package/src/replication/bufferToSqlite.ts +43 -23
- package/src/replication/estimateFilteredSnapshotCount.ts +93 -0
- package/src/replication/replication-index.ts +7 -0
- package/src/test-utils/ChangeStreamTestContext.ts +520 -0
- package/src/test-utils/test-utils-index.ts +1 -0
- package/test/src/buffer_to_sqlite.test.ts +9 -0
- package/test/src/change_event_memory.test.ts +33 -0
- package/test/src/change_stream.test.ts +3 -3
- package/test/src/change_stream_abort.test.ts +110 -0
- package/test/src/change_stream_test_setup.ts +38 -0
- package/test/src/chunked_snapshot.test.ts +3 -3
- package/test/src/documentdb_mode.test.ts +3 -3
- package/test/src/filtering_example.test.ts +65 -0
- package/test/src/filtering_lifecycle.test.ts +101 -0
- package/test/src/query_provider_availability.test.ts +77 -0
- package/test/src/query_provider_diagnostics.test.ts +55 -0
- package/test/src/replication_stream.test.ts +688 -0
- package/test/src/resume.test.ts +10 -4
- package/test/src/resuming_snapshots.test.ts +3 -3
- package/test/src/schema.test.ts +62 -19
- package/test/src/slow_tests.test.ts +3 -3
- package/test/src/snapshot_count.test.ts +146 -0
- package/test/src/snapshot_count_integration.test.ts +70 -0
- package/test/src/snapshot_query_provider.test.ts +122 -0
- package/test/src/stream_progress.test.ts +334 -0
- package/test/tsconfig.json +2 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.benchmark.config.ts +15 -0
- package/test/src/change_stream_utils.ts +0 -375
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER } from '@module/replication/MongoReplicationQueryProvider.js';
|
|
2
|
+
import {
|
|
3
|
+
createMongoReplicationStreamBatch,
|
|
4
|
+
filterMongoReplicationStream
|
|
5
|
+
} from '@module/replication/MongoReplicationStream.js';
|
|
6
|
+
import type { MongoReplicationStreamBatch } from '@module/replication/MongoReplicationStreamTypes.js';
|
|
7
|
+
import { MongoReplicationFilterResult } from '@module/replication/MongoReplicationStreamTypes.js';
|
|
8
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
9
|
+
import { openChangeStreamTestContext } from './change_stream_test_setup.js';
|
|
10
|
+
import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
|
|
11
|
+
import { describeWithStorage } from './util.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Use real source changes and bucket storage to distinguish saved recovery positions from published
|
|
15
|
+
* checkpoints. Reopening with doNotClear preserves both databases so recovery exercises persisted state.
|
|
16
|
+
*/
|
|
17
|
+
describe.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('MongoDB durable adapter progress', () => {
|
|
18
|
+
describeWithStorage({ timeout: 30_000 }, ({ factory, storageVersion }) => {
|
|
19
|
+
test('does not acknowledge retained rows when flushing a later filtered boundary fails', async () => {
|
|
20
|
+
// Finish an empty snapshot and stop replication before inserting data. The next attempt must
|
|
21
|
+
// read these inserts from the change stream, starting at the recorded durable resume position.
|
|
22
|
+
let originalLsn: string;
|
|
23
|
+
{
|
|
24
|
+
await using context = await openChangeStreamTestContext(factory, { storageVersion });
|
|
25
|
+
await context.updateSyncRules(/* yaml */ `
|
|
26
|
+
# Sync config fixture.
|
|
27
|
+
config:
|
|
28
|
+
edition: 3
|
|
29
|
+
streams:
|
|
30
|
+
global:
|
|
31
|
+
query: SELECT _id AS id FROM documents WHERE _id = 1
|
|
32
|
+
`);
|
|
33
|
+
await context.db.createCollection('documents');
|
|
34
|
+
await context.replicateSnapshot();
|
|
35
|
+
await context.getCheckpoint();
|
|
36
|
+
await context.stop();
|
|
37
|
+
originalLsn = (await context.storage!.getStatus()).resumeLsn!;
|
|
38
|
+
await context.db
|
|
39
|
+
.collection<{ _id: number }>('documents')
|
|
40
|
+
.insertMany(Array.from({ length: 20 }, (_, i) => ({ _id: i + 1 })));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Keep document 1 and discard the remaining documents in the provider. This leaves a real row
|
|
44
|
+
// write pending when the provider forwards progress covering both retained and excluded changes.
|
|
45
|
+
let failFlush = false;
|
|
46
|
+
const provider = {
|
|
47
|
+
...DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
|
|
48
|
+
openChangeStream: ({ open }: Parameters<typeof DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER.openChangeStream>[0]) =>
|
|
49
|
+
(async function* (): AsyncGenerator<MongoReplicationStreamBatch> {
|
|
50
|
+
let retained = false;
|
|
51
|
+
const filteredStream = filterMongoReplicationStream({
|
|
52
|
+
batches: open({}),
|
|
53
|
+
filter: (event) => {
|
|
54
|
+
if (event.ns.coll == 'documents') {
|
|
55
|
+
if (String(event.documentKey._id) != '1') {
|
|
56
|
+
return MongoReplicationFilterResult.EXCLUDE;
|
|
57
|
+
}
|
|
58
|
+
retained = true;
|
|
59
|
+
}
|
|
60
|
+
return MongoReplicationFilterResult.KEEP;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
for await (const batch of filteredStream) {
|
|
64
|
+
yield createMongoReplicationStreamBatch({
|
|
65
|
+
events: batch.events,
|
|
66
|
+
resolveResumeToken: () => {
|
|
67
|
+
const progress = batch.getResumeToken();
|
|
68
|
+
if (progress == null || (retained && batch.metadata.filteredCount == 0)) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
retained = false;
|
|
72
|
+
return progress;
|
|
73
|
+
},
|
|
74
|
+
resolveMetadata: () => batch.metadata
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
})()
|
|
78
|
+
};
|
|
79
|
+
{
|
|
80
|
+
const save = vi.fn();
|
|
81
|
+
const setResumeLsn = vi.fn();
|
|
82
|
+
await using context = await openChangeStreamTestContext(factory, {
|
|
83
|
+
storageVersion,
|
|
84
|
+
doNotClear: true,
|
|
85
|
+
streamOptions: {
|
|
86
|
+
snapshotChunkLength: 5,
|
|
87
|
+
createReplicationQueryProvider: () => provider,
|
|
88
|
+
storageHooks: {
|
|
89
|
+
beforeBatchFlush: async () => {
|
|
90
|
+
if (failFlush) throw new Error('injected retained-row flush failure');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
await context.loadActiveSyncRules();
|
|
96
|
+
context.storage!.registerListener({
|
|
97
|
+
batchStarted: (batch) => {
|
|
98
|
+
const originalSave = batch.save.bind(batch);
|
|
99
|
+
vi.spyOn(batch, 'save').mockImplementation((...args) => {
|
|
100
|
+
// Arm the failure only once a retained row reaches storage; setup flushes may succeed.
|
|
101
|
+
save();
|
|
102
|
+
failFlush = true;
|
|
103
|
+
return originalSave(...args);
|
|
104
|
+
});
|
|
105
|
+
const originalSetResumeLsn = batch.setResumeLsn.bind(batch);
|
|
106
|
+
vi.spyOn(batch, 'setResumeLsn').mockImplementation((...args) => {
|
|
107
|
+
setResumeLsn();
|
|
108
|
+
return originalSetResumeLsn(...args);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
const result = await context.startStreaming();
|
|
113
|
+
expect(result).toMatchObject({ status: 'rejected', reason: expect.any(Error) });
|
|
114
|
+
if (result.status == 'rejected') expect(String(result.reason)).toContain('retained-row flush failure');
|
|
115
|
+
// Saving the later token despite the failed flush would skip document 1 on restart.
|
|
116
|
+
expect(save).toHaveBeenCalledOnce();
|
|
117
|
+
expect(setResumeLsn).not.toHaveBeenCalled();
|
|
118
|
+
expect((await context.storage!.getStatus()).resumeLsn).toBe(originalLsn);
|
|
119
|
+
}
|
|
120
|
+
// Reopen from the last durable token with the same selection. The retained row must be replayed.
|
|
121
|
+
await using context = await openChangeStreamTestContext(factory, {
|
|
122
|
+
storageVersion,
|
|
123
|
+
doNotClear: true,
|
|
124
|
+
streamOptions: { createReplicationQueryProvider: () => provider }
|
|
125
|
+
});
|
|
126
|
+
await context.loadActiveSyncRules();
|
|
127
|
+
context.startStreaming();
|
|
128
|
+
expect(
|
|
129
|
+
(await context.getBucketData('global|0[]')).filter((op) => op.op == 'PUT').map((op) => op.object_id)
|
|
130
|
+
).toEqual(['1']);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('resumes ordinary changes from a token inside a transaction', async () => {
|
|
134
|
+
// Insert one transaction after stopping replication. Its 50 events share a clusterTime but span
|
|
135
|
+
// multiple small cursor batches, allowing recovery to stop partway through the transaction.
|
|
136
|
+
{
|
|
137
|
+
await using context = await openChangeStreamTestContext(factory, { storageVersion });
|
|
138
|
+
await context.updateSyncRules(
|
|
139
|
+
'config:\n edition: 3\nstreams:\n global:\n query: SELECT _id AS id FROM documents'
|
|
140
|
+
);
|
|
141
|
+
await context.db.createCollection('documents');
|
|
142
|
+
await context.replicateSnapshot();
|
|
143
|
+
await context.getCheckpoint();
|
|
144
|
+
await context.stop();
|
|
145
|
+
const session = context.client.startSession();
|
|
146
|
+
await using sessionDisposer = { [Symbol.asyncDispose]: () => session.endSession() };
|
|
147
|
+
await session.withTransaction(async () => {
|
|
148
|
+
await context.db.collection<{ _id: string }>('documents').insertMany(
|
|
149
|
+
Array.from({ length: 50 }, (_, index) => ({ _id: String(index) })),
|
|
150
|
+
{ session }
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
{
|
|
155
|
+
let progressCount = 0;
|
|
156
|
+
await using context = await openChangeStreamTestContext(factory, {
|
|
157
|
+
storageVersion,
|
|
158
|
+
doNotClear: true,
|
|
159
|
+
streamOptions: { snapshotChunkLength: 5 }
|
|
160
|
+
});
|
|
161
|
+
await context.loadActiveSyncRules();
|
|
162
|
+
context.storage!.registerListener({
|
|
163
|
+
batchStarted: (batch) => {
|
|
164
|
+
const setResumeLsn = batch.setResumeLsn.bind(batch);
|
|
165
|
+
vi.spyOn(batch, 'setResumeLsn').mockImplementation(async (lsn) => {
|
|
166
|
+
await setResumeLsn(lsn);
|
|
167
|
+
// Abort only after the third position is persisted, so reopening uses an exact token
|
|
168
|
+
// inside the transaction rather than the position recorded before any of its inserts.
|
|
169
|
+
if (++progressCount == 3) context.abort(new Error('stop inside transaction'));
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
await context.startStreaming();
|
|
174
|
+
expect(progressCount).toBe(3);
|
|
175
|
+
}
|
|
176
|
+
// Resume with the ordinary provider. Already flushed rows and the replayed suffix must together
|
|
177
|
+
// produce all 50 inserts; resuming must not discard later events with the same timestamp.
|
|
178
|
+
await using context = await openChangeStreamTestContext(factory, { storageVersion, doNotClear: true });
|
|
179
|
+
await context.loadActiveSyncRules();
|
|
180
|
+
context.startStreaming();
|
|
181
|
+
await context.getCheckpoint();
|
|
182
|
+
const operations = await context.getBucketData('global|0[]');
|
|
183
|
+
// Every insert in this transaction shares a clusterTime, but each has its own resume token.
|
|
184
|
+
// A timestamp dedupe guard here would lose the unprocessed suffix after the third batch.
|
|
185
|
+
expect(
|
|
186
|
+
operations
|
|
187
|
+
.filter((op) => op.op == 'PUT')
|
|
188
|
+
.map((op) => op.object_id)
|
|
189
|
+
.sort()
|
|
190
|
+
).toEqual(Array.from({ length: 50 }, (_, index) => String(index)).sort());
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test.each(['progress', 'flush failure', 'adapter failure'] as const)(
|
|
194
|
+
'%s before the pending checkpoint arrives',
|
|
195
|
+
async (scenario) => {
|
|
196
|
+
// Exclude every document change and stop before the startup checkpoint marker is consumed.
|
|
197
|
+
// Successful boundaries should advance recovery without publishing a checkpoint; failures
|
|
198
|
+
// either before yielding progress or while flushing it must preserve the previous position.
|
|
199
|
+
let originalLsn: string;
|
|
200
|
+
{
|
|
201
|
+
await using context = await openChangeStreamTestContext(factory, { storageVersion });
|
|
202
|
+
await context.updateSyncRules(
|
|
203
|
+
'config:\n edition: 3\nstreams:\n global:\n query: SELECT _id AS id FROM documents'
|
|
204
|
+
);
|
|
205
|
+
await context.db.createCollection('documents');
|
|
206
|
+
await context.replicateSnapshot();
|
|
207
|
+
await context.getCheckpoint();
|
|
208
|
+
await context.stop();
|
|
209
|
+
originalLsn = (await context.storage!.getStatus()).resumeLsn!;
|
|
210
|
+
// All changes precede the barrier which the next replication attempt creates on startup.
|
|
211
|
+
await context.db
|
|
212
|
+
.collection<{ _id: number }>('documents')
|
|
213
|
+
.insertMany(Array.from({ length: 100 }, (_, _id) => ({ _id })));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const savedPositions: string[] = [];
|
|
217
|
+
let filtered = 0;
|
|
218
|
+
let beforeFailure: string | undefined;
|
|
219
|
+
let failFlush = false;
|
|
220
|
+
const save = vi.fn();
|
|
221
|
+
const resolve = vi.fn();
|
|
222
|
+
const commit = vi.fn();
|
|
223
|
+
await using context = await openChangeStreamTestContext(factory, {
|
|
224
|
+
storageVersion,
|
|
225
|
+
doNotClear: true,
|
|
226
|
+
streamOptions: {
|
|
227
|
+
snapshotChunkLength: 5,
|
|
228
|
+
// Keep the test inside the idle keepalive interval: filtered activity must still save
|
|
229
|
+
// progress while the checkpoint barrier is pending, rather than being treated as idle.
|
|
230
|
+
keepaliveIntervalMs: 60_000,
|
|
231
|
+
createReplicationQueryProvider: () => ({
|
|
232
|
+
...DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
|
|
233
|
+
openChangeStream({ open }) {
|
|
234
|
+
return (async function* (): AsyncGenerator<MongoReplicationStreamBatch> {
|
|
235
|
+
for await (const batch of open({})) {
|
|
236
|
+
let count = 0;
|
|
237
|
+
const positionBeforeBatch = (await context.storage!.getStatus()).resumeLsn!;
|
|
238
|
+
const events = (function* () {
|
|
239
|
+
for (const change of batch.events) {
|
|
240
|
+
if ('ns' in change.event && change.event.ns.coll == 'documents') {
|
|
241
|
+
count++;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
yield change;
|
|
245
|
+
}
|
|
246
|
+
})();
|
|
247
|
+
let forwardedProgress = false;
|
|
248
|
+
yield createMongoReplicationStreamBatch({
|
|
249
|
+
events: events,
|
|
250
|
+
resolveResumeToken: () => {
|
|
251
|
+
const progress = batch.getResumeToken();
|
|
252
|
+
if (progress == null || count == 0) {
|
|
253
|
+
return progress;
|
|
254
|
+
}
|
|
255
|
+
filtered += count;
|
|
256
|
+
beforeFailure = positionBeforeBatch;
|
|
257
|
+
if (scenario == 'adapter failure') throw new Error('injected adapter failure');
|
|
258
|
+
failFlush = scenario == 'flush failure';
|
|
259
|
+
forwardedProgress = true;
|
|
260
|
+
return progress;
|
|
261
|
+
},
|
|
262
|
+
resolveMetadata: () => ({ filteredCount: count })
|
|
263
|
+
});
|
|
264
|
+
if (forwardedProgress) {
|
|
265
|
+
// next() is requested only after this module has handled the batch, including its resume token. Inspect actual
|
|
266
|
+
// persisted state here, without sleeps or waiting for a later checkpoint event.
|
|
267
|
+
savedPositions.push((await context.storage!.getStatus()).resumeLsn!);
|
|
268
|
+
// End the successful case deliberately while the later checkpoint marker is
|
|
269
|
+
// still pending, so assertions observe progress independently of a commit.
|
|
270
|
+
if (savedPositions.length == 3) throw new Error('stopped after three safe boundaries');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
})();
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
await context.loadActiveSyncRules();
|
|
279
|
+
const checkpointBefore = await context.storage!.getCheckpoint();
|
|
280
|
+
context.storage!.registerListener({
|
|
281
|
+
batchStarted: (batch) => {
|
|
282
|
+
// All rows are filtered out, so storage may skip beforeBatchFlush for an empty batch.
|
|
283
|
+
// Inject at the public flush boundary to test flush-before-resume ordering on every backend.
|
|
284
|
+
const originalFlush = batch.flush.bind(batch);
|
|
285
|
+
vi.spyOn(batch, 'flush').mockImplementation(async (...args) => {
|
|
286
|
+
if (failFlush) throw new Error('injected flush failure');
|
|
287
|
+
return originalFlush(...args);
|
|
288
|
+
});
|
|
289
|
+
const originalSave = batch.save.bind(batch);
|
|
290
|
+
vi.spyOn(batch, 'save').mockImplementation((...args) => {
|
|
291
|
+
save();
|
|
292
|
+
return originalSave(...args);
|
|
293
|
+
});
|
|
294
|
+
// Observe calls while retaining the real relation resolver and commit behavior.
|
|
295
|
+
const originalResolve = batch.resolveTables.bind(batch);
|
|
296
|
+
vi.spyOn(batch, 'resolveTables').mockImplementation((...args) => {
|
|
297
|
+
resolve();
|
|
298
|
+
return originalResolve(...args);
|
|
299
|
+
});
|
|
300
|
+
const originalCommit = batch.commit.bind(batch);
|
|
301
|
+
vi.spyOn(batch, 'commit').mockImplementation((...args) => {
|
|
302
|
+
commit();
|
|
303
|
+
return originalCommit(...args);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
const result = await context.startStreaming();
|
|
308
|
+
expect(result.status).toBe('rejected');
|
|
309
|
+
if (result.status != 'rejected') throw new Error('Expected the controlled stop');
|
|
310
|
+
expect(String(result.reason)).toContain(
|
|
311
|
+
scenario == 'progress' ? 'three safe boundaries' : `injected ${scenario}`
|
|
312
|
+
);
|
|
313
|
+
expect(filtered).toBeGreaterThan(0);
|
|
314
|
+
// Excluded changes must bypass relation lookup and row writes. No checkpoint marker should
|
|
315
|
+
// have been committed before this attempt stops, even in the successful-progress scenario.
|
|
316
|
+
expect(save).not.toHaveBeenCalled();
|
|
317
|
+
expect(resolve).not.toHaveBeenCalled();
|
|
318
|
+
expect(commit).not.toHaveBeenCalled();
|
|
319
|
+
const status = await context.storage!.getStatus();
|
|
320
|
+
if (scenario == 'progress') {
|
|
321
|
+
// Each handled boundary has its own durable position, despite producing no bucket rows.
|
|
322
|
+
expect(new Set(savedPositions).size).toBe(3);
|
|
323
|
+
expect(savedPositions.every((lsn) => lsn > originalLsn)).toBe(true);
|
|
324
|
+
expect(status.resumeLsn).toBe(savedPositions.at(-1));
|
|
325
|
+
} else {
|
|
326
|
+
// Neither failure path may acknowledge the boundary that failed to finish processing.
|
|
327
|
+
expect(status.resumeLsn).toBe(beforeFailure);
|
|
328
|
+
}
|
|
329
|
+
// Persisting source progress is not permission to expose a checkpoint past the pending barrier.
|
|
330
|
+
expect((await context.storage!.getCheckpoint()).lsn).toBe(checkpointBefore.lsn);
|
|
331
|
+
}
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
});
|
package/test/tsconfig.json
CHANGED