@powersync/service-module-mongodb 0.21.2 → 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.
Files changed (90) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/benchmarks/env.ts +11 -0
  3. package/benchmarks/replication_branch_throughput.benchmark.ts +240 -0
  4. package/dist/api/MongoRouteAPIAdapter.d.ts +6 -0
  5. package/dist/api/MongoRouteAPIAdapter.js +21 -2
  6. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/module/MongoModule.d.ts +1 -1
  11. package/dist/module/MongoModule.js +1 -0
  12. package/dist/module/MongoModule.js.map +1 -1
  13. package/dist/replication/ChangeStream.d.ts +13 -3
  14. package/dist/replication/ChangeStream.js +130 -196
  15. package/dist/replication/ChangeStream.js.map +1 -1
  16. package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
  17. package/dist/replication/ChangeStreamReplicationJob.js +19 -8
  18. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  19. package/dist/replication/ChangeStreamReplicator.d.ts +4 -0
  20. package/dist/replication/ChangeStreamReplicator.js +6 -0
  21. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  22. package/dist/replication/MongoReplicationQueryProvider.d.ts +85 -0
  23. package/dist/replication/MongoReplicationQueryProvider.js +23 -0
  24. package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
  25. package/dist/replication/MongoReplicationStream.d.ts +74 -0
  26. package/dist/replication/MongoReplicationStream.js +228 -0
  27. package/dist/replication/MongoReplicationStream.js.map +1 -0
  28. package/dist/replication/MongoReplicationStreamTypes.d.ts +157 -0
  29. package/dist/replication/MongoReplicationStreamTypes.js +19 -0
  30. package/dist/replication/MongoReplicationStreamTypes.js.map +1 -0
  31. package/dist/replication/MongoSnapshotQuery.d.ts +2 -0
  32. package/dist/replication/MongoSnapshotQuery.js +12 -2
  33. package/dist/replication/MongoSnapshotQuery.js.map +1 -1
  34. package/dist/replication/MongoSnapshotter.d.ts +7 -2
  35. package/dist/replication/MongoSnapshotter.js +90 -88
  36. package/dist/replication/MongoSnapshotter.js.map +1 -1
  37. package/dist/replication/RawChangeStream.d.ts +1 -1
  38. package/dist/replication/RawChangeStream.js +1 -1
  39. package/dist/replication/RawChangeStream.js.map +1 -1
  40. package/dist/replication/estimateFilteredSnapshotCount.d.ts +12 -0
  41. package/dist/replication/estimateFilteredSnapshotCount.js +79 -0
  42. package/dist/replication/estimateFilteredSnapshotCount.js.map +1 -0
  43. package/dist/replication/replication-index.d.ts +7 -0
  44. package/dist/replication/replication-index.js +7 -0
  45. package/dist/replication/replication-index.js.map +1 -1
  46. package/dist/test-utils/ChangeStreamTestContext.d.ts +131 -0
  47. package/dist/test-utils/ChangeStreamTestContext.js +390 -0
  48. package/dist/test-utils/ChangeStreamTestContext.js.map +1 -0
  49. package/dist/test-utils/test-utils-index.d.ts +1 -0
  50. package/dist/test-utils/test-utils-index.js +2 -0
  51. package/dist/test-utils/test-utils-index.js.map +1 -0
  52. package/package.json +11 -10
  53. package/src/api/MongoRouteAPIAdapter.ts +26 -2
  54. package/src/index.ts +1 -0
  55. package/src/module/MongoModule.ts +2 -1
  56. package/src/replication/ChangeStream.ts +143 -226
  57. package/src/replication/ChangeStreamReplicationJob.ts +21 -9
  58. package/src/replication/ChangeStreamReplicator.ts +9 -0
  59. package/src/replication/MongoReplicationQueryProvider.ts +106 -0
  60. package/src/replication/MongoReplicationStream.ts +314 -0
  61. package/src/replication/MongoReplicationStreamTypes.ts +169 -0
  62. package/src/replication/MongoSnapshotQuery.ts +19 -3
  63. package/src/replication/MongoSnapshotter.ts +101 -92
  64. package/src/replication/RawChangeStream.ts +2 -2
  65. package/src/replication/estimateFilteredSnapshotCount.ts +93 -0
  66. package/src/replication/replication-index.ts +7 -0
  67. package/src/test-utils/ChangeStreamTestContext.ts +520 -0
  68. package/src/test-utils/test-utils-index.ts +1 -0
  69. package/test/src/change_event_memory.test.ts +33 -0
  70. package/test/src/change_stream.test.ts +3 -3
  71. package/test/src/change_stream_abort.test.ts +110 -0
  72. package/test/src/change_stream_test_setup.ts +38 -0
  73. package/test/src/chunked_snapshot.test.ts +3 -3
  74. package/test/src/documentdb_mode.test.ts +3 -3
  75. package/test/src/filtering_example.test.ts +65 -0
  76. package/test/src/filtering_lifecycle.test.ts +101 -0
  77. package/test/src/query_provider_availability.test.ts +77 -0
  78. package/test/src/query_provider_diagnostics.test.ts +55 -0
  79. package/test/src/replication_stream.test.ts +688 -0
  80. package/test/src/resume.test.ts +10 -4
  81. package/test/src/resuming_snapshots.test.ts +3 -3
  82. package/test/src/slow_tests.test.ts +3 -3
  83. package/test/src/snapshot_count.test.ts +146 -0
  84. package/test/src/snapshot_count_integration.test.ts +70 -0
  85. package/test/src/snapshot_query_provider.test.ts +122 -0
  86. package/test/src/stream_progress.test.ts +334 -0
  87. package/test/tsconfig.json +2 -2
  88. package/tsconfig.tsbuildinfo +1 -1
  89. package/vitest.benchmark.config.ts +15 -0
  90. package/test/src/change_stream_utils.ts +0 -375
@@ -0,0 +1,38 @@
1
+ import { test_utils } from '@module/index.js';
2
+ import { mongo } from '@powersync/lib-service-mongodb';
3
+ import { TestStorageFactory } from '@powersync/service-core';
4
+ import { METRICS_HELPER } from '@powersync/service-core-tests';
5
+ import { TEST_CONNECTION_OPTIONS } from './util.js';
6
+
7
+ export const ChangeStreamTestContext = test_utils.ChangeStreamTestContext;
8
+
9
+ /**
10
+ * Supply this suite's environment and metrics to the exported context.
11
+ */
12
+ export function openChangeStreamTestContext(
13
+ factory: TestStorageFactory,
14
+ options?: Omit<test_utils.ChangeStreamTestContextOptions, 'factory' | 'connectionOptions' | 'metrics'> & {
15
+ mongoOptions?: Partial<test_utils.ChangeStreamTestContextOptions['connectionOptions']>;
16
+ }
17
+ ) {
18
+ return ChangeStreamTestContext.open({
19
+ ...options,
20
+ factory,
21
+ connectionOptions: { ...TEST_CONNECTION_OPTIONS, ...options?.mongoOptions },
22
+ metrics: METRICS_HELPER.metricsEngine
23
+ });
24
+ }
25
+
26
+ export async function setSnapshotHistorySeconds(client: mongo.MongoClient, seconds: number) {
27
+ const { minSnapshotHistoryWindowInSeconds: currentValue } = await client
28
+ .db('admin')
29
+ .command({ getParameter: 1, minSnapshotHistoryWindowInSeconds: 1 });
30
+
31
+ await client.db('admin').command({ setParameter: 1, minSnapshotHistoryWindowInSeconds: seconds });
32
+
33
+ return {
34
+ async [Symbol.asyncDispose]() {
35
+ await client.db('admin').command({ setParameter: 1, minSnapshotHistoryWindowInSeconds: currentValue });
36
+ }
37
+ };
38
+ }
@@ -5,7 +5,7 @@ import { JSONBig } from '@powersync/service-jsonbig';
5
5
  import { SqliteJsonValue } from '@powersync/service-sync-rules';
6
6
  import * as timers from 'timers/promises';
7
7
  import { describe, expect, test } from 'vitest';
8
- import { ChangeStreamTestContext } from './change_stream_utils.js';
8
+ import { openChangeStreamTestContext } from './change_stream_test_setup.js';
9
9
  import { describeWithStorage, StorageVersionTestContext } from './util.js';
10
10
 
11
11
  describe('chunked snapshots', () => {
@@ -13,8 +13,8 @@ describe('chunked snapshots', () => {
13
13
  });
14
14
 
15
15
  function defineBatchTests({ factory, storageVersion }: StorageVersionTestContext) {
16
- const openContext = (options?: Parameters<typeof ChangeStreamTestContext.open>[1]) => {
17
- return ChangeStreamTestContext.open(factory, { ...options, storageVersion });
16
+ const openContext = (options?: Parameters<typeof openChangeStreamTestContext>[1]) => {
17
+ return openChangeStreamTestContext(factory, { ...options, storageVersion });
18
18
  };
19
19
 
20
20
  // This is not as sensitive to the id type as postgres, but we still test a couple of cases
@@ -9,7 +9,7 @@ import { SentinelLSN } from '@module/common/SentinelLSN.js';
9
9
  import { createSentinelCheckpointLsn, SENTINEL_CHECKPOINT_ID } from '@module/replication/MongoRelation.js';
10
10
  import { CHECKPOINTS_COLLECTION } from '@module/replication/replication-utils.js';
11
11
  import { mongo } from '@powersync/lib-service-mongodb';
12
- import { ChangeStreamTestContext } from './change_stream_utils.js';
12
+ import { openChangeStreamTestContext } from './change_stream_test_setup.js';
13
13
  import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
14
14
  import { env } from './env.js';
15
15
  import { testTimeout } from './test-timeouts.js';
@@ -584,8 +584,8 @@ describe.skipIf(DATABASE_TYPE != DatabaseType.DOCUMENTDB)('documentDbMode', () =
584
584
  });
585
585
 
586
586
  function defineDocumentDBDbModeTests({ factory, storageVersion }: StorageVersionTestContext) {
587
- const openContext = (options?: Parameters<typeof ChangeStreamTestContext.open>[1]) => {
588
- return ChangeStreamTestContext.open(factory, {
587
+ const openContext = (options?: Parameters<typeof openChangeStreamTestContext>[1]) => {
588
+ return openChangeStreamTestContext(factory, {
589
589
  ...options,
590
590
  storageVersion,
591
591
  streamOptions: {
@@ -0,0 +1,65 @@
1
+ import {
2
+ filterMongoReplicationStream,
3
+ MongoReplicationFilter,
4
+ MongoReplicationFilterResult,
5
+ MongoReplicationQueryProvider,
6
+ parseMongoReplicationStream,
7
+ RawMongoReplicationStreamBatch
8
+ } from '@module/index.js';
9
+ import { mongo } from '@powersync/lib-service-mongodb';
10
+ import { expect, test } from 'vitest';
11
+
12
+ /**
13
+ * Minimal provider example: use the same immutable ID prefix for snapshots and streaming.
14
+ * Real BSON parsing and the public filtering helper run without a database connection.
15
+ */
16
+ test('example: replicate only documents with public IDs and preserve filtered progress', async () => {
17
+ const filter: MongoReplicationFilter = (event) =>
18
+ String(event.documentKey._id).startsWith('public-')
19
+ ? MongoReplicationFilterResult.KEEP
20
+ : MongoReplicationFilterResult.EXCLUDE;
21
+ const provider: MongoReplicationQueryProvider = {
22
+ getSnapshotFilter: () => ({ $expr: { $regexMatch: { input: '$_id', regex: '^public-' } } }),
23
+ openChangeStream: ({ open }) => filterMongoReplicationStream({ batches: open({}), filter })
24
+ };
25
+
26
+ async function* source(): AsyncGenerator<RawMongoReplicationStreamBatch> {
27
+ for (const ids of [['public-one', 'private-one', 'public-two'], ['private-two']]) {
28
+ yield {
29
+ events: ids.map((_id) =>
30
+ Buffer.from(
31
+ mongo.BSON.serialize({
32
+ _id: { _data: _id },
33
+ operationType: 'insert',
34
+ ns: { db: 'app', coll: 'documents' },
35
+ documentKey: { _id },
36
+ fullDocument: { _id }
37
+ })
38
+ )
39
+ ),
40
+ resumeToken: { _data: ids.at(-1) }
41
+ };
42
+ }
43
+ }
44
+
45
+ const stream = provider.openChangeStream({
46
+ open: () => parseMongoReplicationStream({ batches: source(), defaultSchema: 'app', multipleDatabases: false })
47
+ });
48
+ const retainedIds = [];
49
+ const progress = [];
50
+ for await (const batch of stream) {
51
+ for (const { event } of batch.events) {
52
+ if ('documentKey' in event) {
53
+ retainedIds.push(event.documentKey._id);
54
+ }
55
+ }
56
+ // Read progress only after exhausting the events, including a fully filtered batch.
57
+ progress.push({ token: batch.getResumeToken(), filteredCount: batch.metadata.filteredCount });
58
+ }
59
+
60
+ expect(retainedIds).toEqual(['public-one', 'public-two']);
61
+ expect(progress).toEqual([
62
+ { token: { _data: 'public-two' }, filteredCount: 1 },
63
+ { token: { _data: 'private-two' }, filteredCount: 1 }
64
+ ]);
65
+ });
@@ -0,0 +1,101 @@
1
+ import {
2
+ filterMongoReplicationStream,
3
+ MongoReplicationFilterResult,
4
+ MongoReplicationQueryProvider,
5
+ PostImagesOption
6
+ } from '@module/index.js';
7
+ import { mongo } from '@powersync/lib-service-mongodb';
8
+ import { describe, expect, test } 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
+ const CONFIG = /* yaml */ `
14
+ # Sync config fixture.
15
+ config:
16
+ edition: 3
17
+ streams:
18
+ global:
19
+ query: SELECT _id AS id, active FROM documents
20
+ `;
21
+
22
+ /**
23
+ * Filter membership is decided by the provider, not by the SQL query, so bucket assertions detect leaks.
24
+ * Required pre/post-images let the provider distinguish entries, exits and source deletes precisely.
25
+ */
26
+ const PROVIDER: MongoReplicationQueryProvider = {
27
+ getSnapshotFilter: () => ({ active: true }),
28
+ openChangeStream: ({ open }) =>
29
+ filterMongoReplicationStream({
30
+ batches: open({ imageOptions: { fullDocumentBeforeChange: 'required' } }),
31
+ filter: (event) => {
32
+ const after = 'fullDocument' in event ? event.fullDocument : undefined;
33
+ const before = 'fullDocumentBeforeChange' in event ? event.fullDocumentBeforeChange : undefined;
34
+ if (after != null && mongo.BSON.deserialize(after).active === true) {
35
+ return MongoReplicationFilterResult.KEEP;
36
+ }
37
+ if (before != null && mongo.BSON.deserialize(before).active === true) {
38
+ return MongoReplicationFilterResult.DELETE;
39
+ }
40
+ return MongoReplicationFilterResult.EXCLUDE;
41
+ }
42
+ })
43
+ };
44
+
45
+ describe.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('MongoDB filtering lifecycle', () => {
46
+ describeWithStorage({ timeout: 30_000 }, ({ factory, storageVersion }) => {
47
+ test('keeps snapshot and streaming membership consistent across updates, deletes and restart', async () => {
48
+ const options = {
49
+ storageVersion,
50
+ mongoOptions: { postImages: PostImagesOption.READ_ONLY },
51
+ streamOptions: { createReplicationQueryProvider: () => PROVIDER }
52
+ };
53
+ let resumeAfter: bigint;
54
+ {
55
+ await using context = await openChangeStreamTestContext(factory, options);
56
+ await context.updateSyncRules(CONFIG);
57
+ const collection = await context.db.createCollection<{ _id: string; active: boolean }>('documents', {
58
+ changeStreamPreAndPostImages: { enabled: true }
59
+ });
60
+ await collection.insertMany([
61
+ { _id: 'one', active: true },
62
+ { _id: 'two', active: false },
63
+ { _id: 'three', active: false }
64
+ ]);
65
+ await context.replicateSnapshot();
66
+ let checkpoint = await context.getCheckpoint();
67
+ expect(await context.getBucketDataAtCheckpoint('global|0[]', checkpoint)).toMatchObject([
68
+ { op: 'PUT', object_id: 'one' }
69
+ ]);
70
+
71
+ // Enter the selected set through a real update, using the server's write-time post-image.
72
+ await collection.updateOne({ _id: 'two' }, { $set: { active: true } });
73
+ expect(await context.getBucketData('global|0[]', checkpoint.checkpoint)).toMatchObject([
74
+ { op: 'PUT', object_id: 'two' }
75
+ ]);
76
+ checkpoint = await context.getCheckpoint();
77
+
78
+ // Leaving the set must emit a synthetic delete rather than merely suppressing the update.
79
+ await collection.updateOne({ _id: 'one' }, { $set: { active: false } });
80
+ await collection.insertOne({ _id: 'excluded', active: false });
81
+ expect(await context.getBucketData('global|0[]', checkpoint.checkpoint)).toMatchObject([
82
+ { op: 'REMOVE', object_id: 'one' }
83
+ ]);
84
+ resumeAfter = (await context.getCheckpoint()).checkpoint;
85
+ await context.stop();
86
+
87
+ // Queue a source delete and another entry while replication is stopped.
88
+ await collection.deleteOne({ _id: 'two' });
89
+ await collection.updateOne({ _id: 'three' }, { $set: { active: true } });
90
+ }
91
+
92
+ await using resumed = await openChangeStreamTestContext(factory, { ...options, doNotClear: true });
93
+ await resumed.loadActiveSyncRules();
94
+ resumed.startStreaming();
95
+ expect(await resumed.getBucketData('global|0[]', resumeAfter)).toMatchObject([
96
+ { op: 'REMOVE', object_id: 'two' },
97
+ { op: 'PUT', object_id: 'three' }
98
+ ]);
99
+ });
100
+ });
101
+ });
@@ -0,0 +1,77 @@
1
+ import { ErrorCode, logger, ServiceError } from '@powersync/lib-services-framework';
2
+ import { MetricsEngine, storage } from '@powersync/service-core';
3
+ import { DEFAULT_HYDRATION_STATE, nodeSqlite, SqlSyncRules } from '@powersync/service-sync-rules';
4
+ import * as sqlite from 'node:sqlite';
5
+ import { afterEach, expect, test, vi } from 'vitest';
6
+ import { ChangeStream } from '../../src/replication/ChangeStream.js';
7
+ import { MongoManager } from '../../src/replication/MongoManager.js';
8
+ import { DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER } from '../../src/replication/MongoReplicationQueryProvider.js';
9
+ import { MongoSnapshotter } from '../../src/replication/MongoSnapshotter.js';
10
+ import { normalizeConnectionConfig } from '../../src/types/types.js';
11
+
12
+ const CONFIG = /* yaml */ `
13
+ # Sync config fixture.
14
+ config:
15
+ edition: 3
16
+ source_table_options:
17
+ orders%:
18
+ mongodb_filter_expression: { $eq: ['$$doc.active', true] }
19
+ orders:
20
+ mongodb_filter_expression: disabled
21
+ streams:
22
+ orders:
23
+ query: SELECT * FROM orders
24
+ `;
25
+ const RAW_CONNECTION = { type: 'mongodb' as const, uri: 'mongodb://localhost/app' };
26
+ const CONNECTION = { ...RAW_CONNECTION, ...normalizeConnectionConfig(RAW_CONNECTION) };
27
+ afterEach(() => vi.restoreAllMocks());
28
+
29
+ test.each([
30
+ { filtered: true, factory: false, blocked: true },
31
+ { filtered: true, factory: true, blocked: false },
32
+ { filtered: false, factory: false, blocked: false }
33
+ ])('checks provider availability: %j', async ({ filtered, factory, blocked }) => {
34
+ const yaml = filtered ? CONFIG : CONFIG.replace("{ $eq: ['$$doc.active', true] }", 'disabled');
35
+ const parsed = SqlSyncRules.fromYaml(yaml, { defaultSchema: 'app' });
36
+ const hydrated = parsed.config.hydrate({ hydrationState: DEFAULT_HYDRATION_STATE, sqlite: nodeSqlite(sqlite) });
37
+ const reportError = vi.fn(async (_error: unknown) => {});
38
+ // Stop allowed cases at the first snapshot check, without connecting to a database.
39
+ const checkSlot = vi
40
+ .spyOn(MongoSnapshotter.prototype, 'checkSlot')
41
+ .mockRejectedValue(new Error('snapshot check reached'));
42
+ const source = new MongoManager(CONNECTION);
43
+ const createProvider = vi.fn(() => DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER);
44
+ try {
45
+ const stream = new ChangeStream({
46
+ connections: source,
47
+ abort_signal: new AbortController().signal,
48
+ metrics: {} as MetricsEngine,
49
+ createReplicationQueryProvider: factory ? createProvider : undefined,
50
+ storage: {
51
+ replicationStreamId: 1,
52
+ replicationStreamName: 'availability',
53
+ getParsedSyncRules: () => hydrated,
54
+ getParsedSyncConfigSet: () => ({ syncConfigs: [parsed] }),
55
+ reportError,
56
+ logger
57
+ } as unknown as storage.SyncRulesBucketStorage
58
+ });
59
+ await expect(stream.replicate()).rejects.toThrow(
60
+ blocked ? 'no query provider factory is registered' : 'snapshot check reached'
61
+ );
62
+ if (blocked) {
63
+ expect(checkSlot).not.toHaveBeenCalled();
64
+ const error = reportError.mock.calls[0]?.[0];
65
+ expect(error).toBeInstanceOf(ServiceError);
66
+ expect(error).toMatchObject({ errorData: { code: ErrorCode.PSYNC_S1348 } });
67
+ expect(reportError).toHaveBeenCalledWith(
68
+ expect.objectContaining({ message: expect.stringContaining('deploy a new sync config') })
69
+ );
70
+ } else {
71
+ expect(checkSlot).toHaveBeenCalledOnce();
72
+ }
73
+ if (factory) expect(createProvider).toHaveBeenCalledWith(expect.objectContaining({ syncConfig: hydrated }));
74
+ } finally {
75
+ await source.client.close();
76
+ }
77
+ });
@@ -0,0 +1,55 @@
1
+ import { DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER } from '@module/index.js';
2
+ import { ErrorCode, ServiceError } from '@powersync/lib-services-framework';
3
+ import { describe, expect, test } from 'vitest';
4
+ import { openChangeStreamTestContext } from './change_stream_test_setup.js';
5
+ import { describeWithStorage } from './util.js';
6
+
7
+ const CONFIG = /* yaml */ `
8
+ # Sync config fixture.
9
+ config:
10
+ edition: 3
11
+ source_table_options:
12
+ orders%:
13
+ mongodb_filter_expression: { $eq: ['$$doc.active', true] }
14
+ orders:
15
+ mongodb_filter_expression: disabled
16
+ streams:
17
+ orders:
18
+ query: SELECT _id AS id FROM orders
19
+ `;
20
+
21
+ describe('MongoDB missing-provider diagnostics', () => {
22
+ describeWithStorage({ timeout: 30_000 }, ({ factory, storageVersion }) => {
23
+ test('persists a missing-provider error for an active config after the provider is removed', async () => {
24
+ {
25
+ await using context = await openChangeStreamTestContext(factory, {
26
+ storageVersion,
27
+ streamOptions: { createReplicationQueryProvider: () => DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER }
28
+ });
29
+ // The concrete collection opts out, but the wildcard still requires provider support.
30
+ await context.updateSyncRules(CONFIG);
31
+ await context.db.createCollection('orders');
32
+ await context.replicateSnapshot();
33
+ await context.getCheckpoint();
34
+ }
35
+ {
36
+ await using context = await openChangeStreamTestContext(factory, { storageVersion, doNotClear: true });
37
+ await context.loadActiveSyncRules();
38
+ const result = await context.startStreaming();
39
+ expect(result.status).toBe('rejected');
40
+ if (result.status != 'rejected') {
41
+ throw new Error('Expected missing-provider rejection');
42
+ }
43
+ expect(result.reason).toBeInstanceOf(ServiceError);
44
+ expect(result.reason).toMatchObject({ errorData: { code: ErrorCode.PSYNC_S1348 } });
45
+ }
46
+ // Reopen storage to prove diagnostics were persisted, rather than only reported in memory.
47
+ await using reopened = await openChangeStreamTestContext(factory, { storageVersion, doNotClear: true });
48
+ const active = await reopened.factory.getActiveSyncConfig();
49
+ expect(active).not.toBeNull();
50
+ const status = await active!.content.getSyncConfigStatus();
51
+ expect(status?.last_fatal_error).toContain('no query provider factory is registered');
52
+ expect(status?.last_fatal_error).toContain('deploy a new sync config');
53
+ });
54
+ });
55
+ });