@powersync/service-module-mssql 0.0.1

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 (92) hide show
  1. package/LICENSE +67 -0
  2. package/README.md +3 -0
  3. package/ci/init-mssql.sql +50 -0
  4. package/dist/api/MSSQLRouteAPIAdapter.d.ts +21 -0
  5. package/dist/api/MSSQLRouteAPIAdapter.js +248 -0
  6. package/dist/api/MSSQLRouteAPIAdapter.js.map +1 -0
  7. package/dist/common/LSN.d.ts +37 -0
  8. package/dist/common/LSN.js +64 -0
  9. package/dist/common/LSN.js.map +1 -0
  10. package/dist/common/MSSQLSourceTable.d.ts +27 -0
  11. package/dist/common/MSSQLSourceTable.js +35 -0
  12. package/dist/common/MSSQLSourceTable.js.map +1 -0
  13. package/dist/common/MSSQLSourceTableCache.d.ts +14 -0
  14. package/dist/common/MSSQLSourceTableCache.js +28 -0
  15. package/dist/common/MSSQLSourceTableCache.js.map +1 -0
  16. package/dist/common/mssqls-to-sqlite.d.ts +18 -0
  17. package/dist/common/mssqls-to-sqlite.js +143 -0
  18. package/dist/common/mssqls-to-sqlite.js.map +1 -0
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.js +2 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/module/MSSQLModule.d.ts +15 -0
  23. package/dist/module/MSSQLModule.js +68 -0
  24. package/dist/module/MSSQLModule.js.map +1 -0
  25. package/dist/replication/CDCPoller.d.ts +67 -0
  26. package/dist/replication/CDCPoller.js +183 -0
  27. package/dist/replication/CDCPoller.js.map +1 -0
  28. package/dist/replication/CDCReplicationJob.d.ts +17 -0
  29. package/dist/replication/CDCReplicationJob.js +76 -0
  30. package/dist/replication/CDCReplicationJob.js.map +1 -0
  31. package/dist/replication/CDCReplicator.d.ts +18 -0
  32. package/dist/replication/CDCReplicator.js +55 -0
  33. package/dist/replication/CDCReplicator.js.map +1 -0
  34. package/dist/replication/CDCStream.d.ts +106 -0
  35. package/dist/replication/CDCStream.js +536 -0
  36. package/dist/replication/CDCStream.js.map +1 -0
  37. package/dist/replication/MSSQLConnectionManager.d.ts +23 -0
  38. package/dist/replication/MSSQLConnectionManager.js +97 -0
  39. package/dist/replication/MSSQLConnectionManager.js.map +1 -0
  40. package/dist/replication/MSSQLConnectionManagerFactory.d.ts +10 -0
  41. package/dist/replication/MSSQLConnectionManagerFactory.js +28 -0
  42. package/dist/replication/MSSQLConnectionManagerFactory.js.map +1 -0
  43. package/dist/replication/MSSQLErrorRateLimiter.d.ts +10 -0
  44. package/dist/replication/MSSQLErrorRateLimiter.js +34 -0
  45. package/dist/replication/MSSQLErrorRateLimiter.js.map +1 -0
  46. package/dist/replication/MSSQLSnapshotQuery.d.ts +71 -0
  47. package/dist/replication/MSSQLSnapshotQuery.js +190 -0
  48. package/dist/replication/MSSQLSnapshotQuery.js.map +1 -0
  49. package/dist/types/mssql-data-types.d.ts +66 -0
  50. package/dist/types/mssql-data-types.js +62 -0
  51. package/dist/types/mssql-data-types.js.map +1 -0
  52. package/dist/types/types.d.ts +177 -0
  53. package/dist/types/types.js +141 -0
  54. package/dist/types/types.js.map +1 -0
  55. package/dist/utils/mssql.d.ts +80 -0
  56. package/dist/utils/mssql.js +329 -0
  57. package/dist/utils/mssql.js.map +1 -0
  58. package/dist/utils/schema.d.ts +21 -0
  59. package/dist/utils/schema.js +131 -0
  60. package/dist/utils/schema.js.map +1 -0
  61. package/package.json +51 -0
  62. package/src/api/MSSQLRouteAPIAdapter.ts +283 -0
  63. package/src/common/LSN.ts +77 -0
  64. package/src/common/MSSQLSourceTable.ts +54 -0
  65. package/src/common/MSSQLSourceTableCache.ts +36 -0
  66. package/src/common/mssqls-to-sqlite.ts +151 -0
  67. package/src/index.ts +1 -0
  68. package/src/module/MSSQLModule.ts +82 -0
  69. package/src/replication/CDCPoller.ts +241 -0
  70. package/src/replication/CDCReplicationJob.ts +87 -0
  71. package/src/replication/CDCReplicator.ts +70 -0
  72. package/src/replication/CDCStream.ts +688 -0
  73. package/src/replication/MSSQLConnectionManager.ts +113 -0
  74. package/src/replication/MSSQLConnectionManagerFactory.ts +33 -0
  75. package/src/replication/MSSQLErrorRateLimiter.ts +36 -0
  76. package/src/replication/MSSQLSnapshotQuery.ts +230 -0
  77. package/src/types/mssql-data-types.ts +79 -0
  78. package/src/types/types.ts +224 -0
  79. package/src/utils/mssql.ts +420 -0
  80. package/src/utils/schema.ts +172 -0
  81. package/test/src/CDCStream.test.ts +206 -0
  82. package/test/src/CDCStreamTestContext.ts +212 -0
  83. package/test/src/CDCStream_resumable_snapshot.test.ts +152 -0
  84. package/test/src/env.ts +11 -0
  85. package/test/src/mssql-to-sqlite.test.ts +474 -0
  86. package/test/src/setup.ts +12 -0
  87. package/test/src/util.ts +189 -0
  88. package/test/tsconfig.json +28 -0
  89. package/test/tsconfig.tsbuildinfo +1 -0
  90. package/tsconfig.json +26 -0
  91. package/tsconfig.tsbuildinfo +1 -0
  92. package/vitest.config.ts +15 -0
@@ -0,0 +1,172 @@
1
+ import { SourceEntityDescriptor } from '@powersync/service-core';
2
+ import { TablePattern } from '@powersync/service-sync-rules';
3
+ import { MSSQLConnectionManager } from '../replication/MSSQLConnectionManager.js';
4
+ import { MSSQLColumnDescriptor } from '../types/mssql-data-types.js';
5
+ import { escapeIdentifier } from './mssql.js';
6
+
7
+ export interface GetColumnsOptions {
8
+ connectionManager: MSSQLConnectionManager;
9
+ schema: string;
10
+ tableName: string;
11
+ }
12
+
13
+ async function getColumns(options: GetColumnsOptions): Promise<MSSQLColumnDescriptor[]> {
14
+ const { connectionManager, schema, tableName } = options;
15
+
16
+ const { recordset: columnResults } = await connectionManager.query(`
17
+ SELECT
18
+ col.name AS [name],
19
+ typ.name AS [type],
20
+ typ.system_type_id AS type_id,
21
+ typ.user_type_id AS user_type_id
22
+ FROM sys.columns AS col
23
+ JOIN sys.tables AS tbl ON tbl.object_id = col.object_id
24
+ JOIN sys.schemas AS sch ON sch.schema_id = tbl.schema_id
25
+ JOIN sys.types AS typ ON typ.user_type_id = col.user_type_id
26
+ WHERE sch.name = '${schema}'
27
+ AND tbl.name = '${tableName}'
28
+ ORDER BY col.column_id;
29
+ `);
30
+
31
+ return columnResults.map((row) => {
32
+ return {
33
+ name: row.name,
34
+ type: row.type,
35
+ typeId: row.type_id,
36
+ userTypeId: row.user_type_id
37
+ };
38
+ });
39
+ }
40
+
41
+ export interface GetReplicationIdentityColumnsOptions {
42
+ connectionManager: MSSQLConnectionManager;
43
+ schema: string;
44
+ tableName: string;
45
+ }
46
+
47
+ export interface ReplicationIdentityColumnsResult {
48
+ columns: MSSQLColumnDescriptor[];
49
+ identity: 'default' | 'nothing' | 'full' | 'index';
50
+ }
51
+
52
+ export async function getReplicationIdentityColumns(
53
+ options: GetReplicationIdentityColumnsOptions
54
+ ): Promise<ReplicationIdentityColumnsResult> {
55
+ const { connectionManager, schema, tableName } = options;
56
+ const { recordset: primaryKeyColumns } = await connectionManager.query(`
57
+ SELECT
58
+ col.name AS [name],
59
+ typ.name AS [type],
60
+ typ.system_type_id AS type_id,
61
+ typ.user_type_id AS user_type_id
62
+ FROM sys.tables AS tbl
63
+ JOIN sys.schemas AS sch ON sch.schema_id = tbl.schema_id
64
+ JOIN sys.indexes AS idx ON idx.object_id = tbl.object_id AND idx.is_primary_key = 1
65
+ JOIN sys.index_columns AS idx_col ON idx_col.object_id = idx.object_id AND idx_col.index_id = idx.index_id
66
+ JOIN sys.columns AS col ON col.object_id = idx_col.object_id AND col.column_id = idx_col.column_id
67
+ JOIN sys.types AS typ ON typ.user_type_id = col.user_type_id
68
+ WHERE sch.name = '${schema}'
69
+ AND tbl.name = '${tableName}'
70
+ ORDER BY idx_col.key_ordinal;
71
+ `);
72
+
73
+ if (primaryKeyColumns.length > 0) {
74
+ return {
75
+ columns: primaryKeyColumns.map((row) => ({
76
+ name: row.name,
77
+ type: row.type,
78
+ typeId: row.type_id,
79
+ userTypeId: row.user_type_id
80
+ })),
81
+ identity: 'default'
82
+ };
83
+ }
84
+
85
+ // No primary key, check if any of the columns have a unique constraint we can use
86
+ const { recordset: uniqueKeyColumns } = await connectionManager.query(`
87
+ SELECT
88
+ col.name AS [name],
89
+ typ.name AS [type],
90
+ typ.system_type_id AS type_id,
91
+ typ.user_type_id AS user_type_id
92
+ FROM sys.tables AS tbl
93
+ JOIN sys.schemas AS sch ON sch.schema_id = tbl.schema_id
94
+ JOIN sys.indexes AS idx ON idx.object_id = tbl.object_id AND idx.is_unique_constraint = 1
95
+ JOIN sys.index_columns AS idx_col ON idx_col.object_id = idx.object_id AND idx_col.index_id = idx.index_id
96
+ JOIN sys.columns AS col ON col.object_id = idx_col.object_id AND col.column_id = idx_col.column_id
97
+ JOIN sys.types AS typ ON typ.user_type_id = col.user_type_id
98
+ WHERE sch.name = '${schema}'
99
+ AND tbl.name = '${tableName}'
100
+ ORDER BY idx_col.key_ordinal;
101
+ `);
102
+
103
+ if (uniqueKeyColumns.length > 0) {
104
+ return {
105
+ columns: uniqueKeyColumns.map((row) => ({
106
+ name: row.name,
107
+ type: row.type,
108
+ typeId: row.type_id,
109
+ userTypeId: row.user_type_id
110
+ })),
111
+ identity: 'index'
112
+ };
113
+ }
114
+
115
+ const allColumns = await getColumns(options);
116
+
117
+ return {
118
+ columns: allColumns,
119
+ identity: 'full'
120
+ };
121
+ }
122
+
123
+ export type ResolvedTable = Omit<SourceEntityDescriptor, 'replicaIdColumns'>;
124
+
125
+ export async function getTablesFromPattern(
126
+ connectionManager: MSSQLConnectionManager,
127
+ tablePattern: TablePattern
128
+ ): Promise<ResolvedTable[]> {
129
+ if (tablePattern.isWildcard) {
130
+ const { recordset: tableResults } = await connectionManager.query(`
131
+ SELECT
132
+ tbl.name AS [table],
133
+ sch.name AS [schema],
134
+ tbl.object_id AS object_id
135
+ FROM sys.tables tbl
136
+ JOIN sys.schemas sch ON tbl.schema_id = sch.schema_id
137
+ WHERE sch.name = '${tablePattern.schema}'
138
+ AND tbl.name LIKE '${tablePattern.tablePattern}'
139
+ `);
140
+
141
+ return tableResults
142
+ .map((row) => {
143
+ return {
144
+ objectId: row.object_id,
145
+ schema: row.schema,
146
+ name: row.table
147
+ };
148
+ })
149
+ .filter((table: ResolvedTable) => table.name.startsWith(tablePattern.tablePrefix));
150
+ } else {
151
+ const { recordset: tableResults } = await connectionManager.query(
152
+ `
153
+ SELECT
154
+ tbl.name AS [table],
155
+ sch.name AS [schema],
156
+ tbl.object_id AS object_id
157
+ FROM sys.tables tbl
158
+ JOIN sys.schemas sch ON tbl.schema_id = sch.schema_id
159
+ WHERE sch.name = '${tablePattern.schema}'
160
+ AND tbl.name = '${tablePattern.name}'
161
+ `
162
+ );
163
+
164
+ return tableResults.map((row) => {
165
+ return {
166
+ objectId: row.object_id,
167
+ schema: row.schema,
168
+ name: row.table
169
+ };
170
+ });
171
+ }
172
+ }
@@ -0,0 +1,206 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { METRICS_HELPER, putOp, removeOp } from '@powersync/service-core-tests';
3
+ import { ReplicationMetric } from '@powersync/service-types';
4
+ import {
5
+ createTestTable,
6
+ describeWithStorage,
7
+ INITIALIZED_MONGO_STORAGE_FACTORY,
8
+ insertTestData,
9
+ waitForPendingCDCChanges
10
+ } from './util.js';
11
+ import { storage } from '@powersync/service-core';
12
+ import { CDCStreamTestContext } from './CDCStreamTestContext.js';
13
+ import { getLatestReplicatedLSN } from '@module/utils/mssql.js';
14
+ import sql from 'mssql';
15
+
16
+ const BASIC_SYNC_RULES = `
17
+ bucket_definitions:
18
+ global:
19
+ data:
20
+ - SELECT id, description FROM "test_data"
21
+ `;
22
+
23
+ // describe('CDCStream tests', () => {
24
+ // describeWithStorage({ timeout: 20_000 }, defineCDCStreamTests);
25
+ // });
26
+
27
+ defineCDCStreamTests(INITIALIZED_MONGO_STORAGE_FACTORY);
28
+
29
+ function defineCDCStreamTests(factory: storage.TestStorageFactory) {
30
+ test('Initial snapshot sync', async () => {
31
+ await using context = await CDCStreamTestContext.open(factory);
32
+ const { connectionManager } = context;
33
+ await context.updateSyncRules(BASIC_SYNC_RULES);
34
+
35
+ await createTestTable(connectionManager, 'test_data');
36
+ const beforeLSN = await getLatestReplicatedLSN(connectionManager);
37
+ const testData = await insertTestData(connectionManager, 'test_data');
38
+ await waitForPendingCDCChanges(beforeLSN, connectionManager);
39
+ const startRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
40
+
41
+ await context.replicateSnapshot();
42
+ await context.startStreaming();
43
+
44
+ const endRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
45
+ const data = await context.getBucketData('global[]');
46
+ expect(data).toMatchObject([putOp('test_data', testData)]);
47
+ expect(endRowCount - startRowCount).toEqual(1);
48
+ });
49
+
50
+ test('Replicate basic values', async () => {
51
+ await using context = await CDCStreamTestContext.open(factory);
52
+ const { connectionManager } = context;
53
+ await context.updateSyncRules(BASIC_SYNC_RULES);
54
+
55
+ await createTestTable(connectionManager, 'test_data');
56
+ await context.replicateSnapshot();
57
+
58
+ const startRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
59
+ const startTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
60
+
61
+ await context.startStreaming();
62
+
63
+ const testData = await insertTestData(connectionManager, 'test_data');
64
+
65
+ const data = await context.getBucketData('global[]');
66
+
67
+ expect(data).toMatchObject([putOp('test_data', testData)]);
68
+ const endRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
69
+ const endTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
70
+ expect(endRowCount - startRowCount).toEqual(1);
71
+ expect(endTxCount - startTxCount).toEqual(1);
72
+ });
73
+
74
+ test('Replicate row updates', async () => {
75
+ await using context = await CDCStreamTestContext.open(factory);
76
+ const { connectionManager } = context;
77
+ await context.updateSyncRules(BASIC_SYNC_RULES);
78
+
79
+ await createTestTable(connectionManager, 'test_data');
80
+ const beforeLSN = await getLatestReplicatedLSN(connectionManager);
81
+ const testData = await insertTestData(connectionManager, 'test_data');
82
+ await waitForPendingCDCChanges(beforeLSN, connectionManager);
83
+ await context.replicateSnapshot();
84
+
85
+ await context.startStreaming();
86
+
87
+ const updatedTestData = { ...testData };
88
+ updatedTestData.description = 'updated';
89
+ await connectionManager.query(`UPDATE test_data SET description = @description WHERE id = @id`, [
90
+ { name: 'description', type: sql.NVarChar(sql.MAX), value: updatedTestData.description },
91
+ { name: 'id', type: sql.UniqueIdentifier, value: updatedTestData.id }
92
+ ]);
93
+
94
+ const data = await context.getBucketData('global[]');
95
+ expect(data).toMatchObject([putOp('test_data', testData), putOp('test_data', updatedTestData)]);
96
+ });
97
+
98
+ test('Replicate row deletions', async () => {
99
+ await using context = await CDCStreamTestContext.open(factory);
100
+ const { connectionManager } = context;
101
+ await context.updateSyncRules(BASIC_SYNC_RULES);
102
+
103
+ await createTestTable(connectionManager, 'test_data');
104
+ const beforeLSN = await getLatestReplicatedLSN(connectionManager);
105
+ const testData = await insertTestData(connectionManager, 'test_data');
106
+ await waitForPendingCDCChanges(beforeLSN, connectionManager);
107
+ await context.replicateSnapshot();
108
+
109
+ await context.startStreaming();
110
+
111
+ await connectionManager.query(`DELETE FROM test_data WHERE id = @id`, [
112
+ { name: 'id', type: sql.UniqueIdentifier, value: testData.id }
113
+ ]);
114
+
115
+ const data = await context.getBucketData('global[]');
116
+ expect(data).toMatchObject([putOp('test_data', testData), removeOp('test_data', testData.id)]);
117
+ });
118
+
119
+ test('Replicate matched wild card tables in sync rules', async () => {
120
+ await using context = await CDCStreamTestContext.open(factory);
121
+ const { connectionManager } = context;
122
+ await context.updateSyncRules(`
123
+ bucket_definitions:
124
+ global:
125
+ data:
126
+ - SELECT id, description FROM "test_data_%"`);
127
+
128
+ await createTestTable(connectionManager, 'test_data_1');
129
+ await createTestTable(connectionManager, 'test_data_2');
130
+
131
+ const beforeLSN = await getLatestReplicatedLSN(connectionManager);
132
+ const testData11 = await insertTestData(connectionManager, 'test_data_1');
133
+ const testData21 = await insertTestData(connectionManager, 'test_data_2');
134
+ await waitForPendingCDCChanges(beforeLSN, connectionManager);
135
+
136
+ await context.replicateSnapshot();
137
+ await context.startStreaming();
138
+
139
+ const testData12 = await insertTestData(connectionManager, 'test_data_1');
140
+ const testData22 = await insertTestData(connectionManager, 'test_data_2');
141
+
142
+ const data = await context.getBucketData('global[]');
143
+
144
+ expect(data).toMatchObject([
145
+ putOp('test_data_1', testData11),
146
+ putOp('test_data_2', testData21),
147
+ putOp('test_data_1', testData12),
148
+ putOp('test_data_2', testData22)
149
+ ]);
150
+ });
151
+
152
+ test('Replication for tables not in the sync rules are ignored', async () => {
153
+ await using context = await CDCStreamTestContext.open(factory);
154
+ const { connectionManager } = context;
155
+ await context.updateSyncRules(BASIC_SYNC_RULES);
156
+
157
+ await createTestTable(connectionManager, 'test_donotsync');
158
+
159
+ await context.replicateSnapshot();
160
+
161
+ const startRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
162
+ const startTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
163
+
164
+ await context.startStreaming();
165
+
166
+ await insertTestData(connectionManager, 'test_donotsync');
167
+ const data = await context.getBucketData('global[]');
168
+
169
+ expect(data).toMatchObject([]);
170
+ const endRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
171
+ const endTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
172
+
173
+ // There was a transaction, but it is not counted since it is not for a table in the sync rules
174
+ expect(endRowCount - startRowCount).toEqual(0);
175
+ expect(endTxCount - startTxCount).toEqual(0);
176
+ });
177
+
178
+ test('Replicate case sensitive table', async () => {
179
+ await using context = await CDCStreamTestContext.open(factory);
180
+ const { connectionManager } = context;
181
+ await context.updateSyncRules(`
182
+ bucket_definitions:
183
+ global:
184
+ data:
185
+ - SELECT id, description FROM "test_DATA"
186
+ `);
187
+
188
+ await createTestTable(connectionManager, 'test_DATA');
189
+
190
+ await context.replicateSnapshot();
191
+
192
+ const startRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
193
+ const startTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
194
+
195
+ await context.startStreaming();
196
+
197
+ const testData = await insertTestData(connectionManager, 'test_DATA');
198
+ const data = await context.getBucketData('global[]');
199
+
200
+ expect(data).toMatchObject([putOp('test_DATA', testData)]);
201
+ const endRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
202
+ const endTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
203
+ expect(endRowCount - startRowCount).toEqual(1);
204
+ expect(endTxCount - startTxCount).toBeGreaterThanOrEqual(1);
205
+ });
206
+ }
@@ -0,0 +1,212 @@
1
+ import {
2
+ BucketStorageFactory,
3
+ createCoreReplicationMetrics,
4
+ initializeCoreReplicationMetrics,
5
+ InternalOpId,
6
+ OplogEntry,
7
+ storage,
8
+ SyncRulesBucketStorage
9
+ } from '@powersync/service-core';
10
+ import { METRICS_HELPER, test_utils } from '@powersync/service-core-tests';
11
+ import { clearTestDb, getClientCheckpoint, TEST_CONNECTION_OPTIONS } from './util.js';
12
+ import { CDCStream, CDCStreamOptions } from '@module/replication/CDCStream.js';
13
+ import { MSSQLConnectionManager } from '@module/replication/MSSQLConnectionManager.js';
14
+ import timers from 'timers/promises';
15
+ import { CDCPollingOptions } from '@module/types/types.js';
16
+
17
+ /**
18
+ * Tests operating on the change data capture need to configure the stream and manage asynchronous
19
+ * replication, which gets a little tricky.
20
+ *
21
+ * This wraps all the context required for testing, and tears it down afterward
22
+ * by using `await using`.
23
+ */
24
+ export class CDCStreamTestContext implements AsyncDisposable {
25
+ private _cdcStream?: CDCStream;
26
+ private abortController = new AbortController();
27
+ private streamPromise?: Promise<void>;
28
+ public storage?: SyncRulesBucketStorage;
29
+ private snapshotPromise?: Promise<void>;
30
+ private replicationDone = false;
31
+
32
+ static async open(
33
+ factory: (options: storage.TestStorageOptions) => Promise<BucketStorageFactory>,
34
+ options?: { doNotClear?: boolean; cdcStreamOptions?: Partial<CDCStreamOptions> }
35
+ ) {
36
+ const f = await factory({ doNotClear: options?.doNotClear });
37
+ const connectionManager = new MSSQLConnectionManager(TEST_CONNECTION_OPTIONS, {});
38
+
39
+ if (!options?.doNotClear) {
40
+ await clearTestDb(connectionManager);
41
+ }
42
+
43
+ return new CDCStreamTestContext(f, connectionManager, options?.cdcStreamOptions);
44
+ }
45
+
46
+ constructor(
47
+ public factory: BucketStorageFactory,
48
+ public connectionManager: MSSQLConnectionManager,
49
+ private cdcStreamOptions?: Partial<CDCStreamOptions>
50
+ ) {
51
+ createCoreReplicationMetrics(METRICS_HELPER.metricsEngine);
52
+ initializeCoreReplicationMetrics(METRICS_HELPER.metricsEngine);
53
+ }
54
+
55
+ async [Symbol.asyncDispose]() {
56
+ try {
57
+ await this.dispose();
58
+ } catch (err) {
59
+ console.error('Error disposing CDCStreamTestContext', err);
60
+ }
61
+ }
62
+
63
+ async dispose() {
64
+ this.abortController.abort();
65
+ await this.snapshotPromise;
66
+ await this.streamPromise;
67
+ await this.connectionManager.end();
68
+ await this.factory?.[Symbol.asyncDispose]();
69
+ }
70
+
71
+ get connectionTag() {
72
+ return this.connectionManager.connectionTag;
73
+ }
74
+
75
+ async updateSyncRules(content: string) {
76
+ const syncRules = await this.factory.updateSyncRules({ content: content, validate: true });
77
+ this.storage = this.factory.getInstance(syncRules);
78
+ return this.storage!;
79
+ }
80
+
81
+ async loadNextSyncRules() {
82
+ const syncRules = await this.factory.getNextSyncRulesContent();
83
+ if (syncRules == null) {
84
+ throw new Error(`Next sync rules not available`);
85
+ }
86
+
87
+ this.storage = this.factory.getInstance(syncRules);
88
+ return this.storage!;
89
+ }
90
+
91
+ async loadActiveSyncRules() {
92
+ const syncRules = await this.factory.getActiveSyncRulesContent();
93
+ if (syncRules == null) {
94
+ throw new Error(`Active sync rules not available`);
95
+ }
96
+
97
+ this.storage = this.factory.getInstance(syncRules);
98
+ return this.storage!;
99
+ }
100
+
101
+ get cdcStream() {
102
+ if (this.storage == null) {
103
+ throw new Error('updateSyncRules() first');
104
+ }
105
+ if (this._cdcStream) {
106
+ return this._cdcStream;
107
+ }
108
+ const options: CDCStreamOptions = {
109
+ storage: this.storage,
110
+ metrics: METRICS_HELPER.metricsEngine,
111
+ connections: this.connectionManager,
112
+ abortSignal: this.abortController.signal,
113
+ pollingOptions: {
114
+ batchSize: 10,
115
+ intervalMs: 1000
116
+ } satisfies CDCPollingOptions,
117
+ ...this.cdcStreamOptions
118
+ };
119
+ this._cdcStream = new CDCStream(options);
120
+ return this._cdcStream!;
121
+ }
122
+
123
+ /**
124
+ * Replicate a snapshot, start streaming, and wait for a consistent checkpoint.
125
+ */
126
+ async initializeReplication() {
127
+ await this.replicateSnapshot();
128
+ // TODO: renable this.startStreaming();
129
+ // Make sure we're up to date
130
+ await this.getCheckpoint();
131
+ }
132
+
133
+ async replicateSnapshot() {
134
+ await this.cdcStream.initReplication();
135
+ this.replicationDone = true;
136
+ }
137
+
138
+ // TODO: Enable once streaming is implemented
139
+ startStreaming() {
140
+ if (!this.replicationDone) {
141
+ throw new Error('Call replicateSnapshot() before startStreaming()');
142
+ }
143
+ this.streamPromise = this.cdcStream.streamChanges();
144
+ // Wait for the replication to start before returning.
145
+ // This avoids a bunch of unpredictable race conditions that appear in testing
146
+ return new Promise<void>(async (resolve) => {
147
+ while (this.cdcStream.isStartingReplication) {
148
+ await timers.setTimeout(50);
149
+ }
150
+
151
+ resolve();
152
+ });
153
+ }
154
+
155
+ async getCheckpoint(options?: { timeout?: number }) {
156
+ let checkpoint = await Promise.race([
157
+ getClientCheckpoint(this.connectionManager, this.factory, { timeout: options?.timeout ?? 15_000 }),
158
+ this.streamPromise
159
+ ]);
160
+ if (checkpoint == null) {
161
+ // This indicates an issue with the test setup - streamingPromise completed instead
162
+ // of getClientCheckpoint()
163
+ throw new Error('Test failure - streamingPromise completed');
164
+ }
165
+ return checkpoint;
166
+ }
167
+
168
+ async getBucketsDataBatch(buckets: Record<string, InternalOpId>, options?: { timeout?: number }) {
169
+ let checkpoint = await this.getCheckpoint(options);
170
+ const map = new Map<string, InternalOpId>(Object.entries(buckets));
171
+ return test_utils.fromAsync(this.storage!.getBucketDataBatch(checkpoint, map));
172
+ }
173
+
174
+ /**
175
+ * This waits for a client checkpoint.
176
+ */
177
+ async getBucketData(bucket: string, start?: InternalOpId | string | undefined, options?: { timeout?: number }) {
178
+ start ??= 0n;
179
+ if (typeof start == 'string') {
180
+ start = BigInt(start);
181
+ }
182
+ const checkpoint = await this.getCheckpoint(options);
183
+ const map = new Map<string, InternalOpId>([[bucket, start]]);
184
+ let data: OplogEntry[] = [];
185
+ while (true) {
186
+ const batch = this.storage!.getBucketDataBatch(checkpoint, map);
187
+
188
+ const batches = await test_utils.fromAsync(batch);
189
+ data = data.concat(batches[0]?.chunkData.data ?? []);
190
+ if (batches.length == 0 || !batches[0]!.chunkData.has_more) {
191
+ break;
192
+ }
193
+ map.set(bucket, BigInt(batches[0]!.chunkData.next_after));
194
+ }
195
+ return data;
196
+ }
197
+
198
+ /**
199
+ * This does not wait for a client checkpoint.
200
+ */
201
+ async getCurrentBucketData(bucket: string, start?: InternalOpId | string | undefined) {
202
+ start ??= 0n;
203
+ if (typeof start == 'string') {
204
+ start = BigInt(start);
205
+ }
206
+ const { checkpoint } = await this.storage!.getCheckpoint();
207
+ const map = new Map<string, InternalOpId>([[bucket, start]]);
208
+ const batch = this.storage!.getBucketDataBatch(checkpoint, map);
209
+ const batches = await test_utils.fromAsync(batch);
210
+ return batches[0]?.chunkData.data ?? [];
211
+ }
212
+ }