@powersync/service-module-postgres 0.17.2 → 0.19.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.
@@ -1,26 +1,31 @@
1
1
  import { PgManager } from '@module/replication/PgManager.js';
2
2
  import { PUBLICATION_NAME, WalStream, WalStreamOptions } from '@module/replication/WalStream.js';
3
+ import { ReplicationAbortedError } from '@powersync/lib-services-framework';
3
4
  import {
4
5
  BucketStorageFactory,
5
6
  createCoreReplicationMetrics,
6
7
  initializeCoreReplicationMetrics,
7
8
  InternalOpId,
9
+ LEGACY_STORAGE_VERSION,
8
10
  OplogEntry,
11
+ settledPromise,
9
12
  storage,
10
- SyncRulesBucketStorage
13
+ STORAGE_VERSION_CONFIG,
14
+ SyncRulesBucketStorage,
15
+ unsettledPromise,
16
+ updateSyncRulesFromYaml
11
17
  } from '@powersync/service-core';
12
- import { METRICS_HELPER, test_utils } from '@powersync/service-core-tests';
18
+ import { bucketRequest, METRICS_HELPER, test_utils } from '@powersync/service-core-tests';
13
19
  import * as pgwire from '@powersync/service-jpgwire';
14
20
  import { clearTestDb, getClientCheckpoint, TEST_CONNECTION_OPTIONS } from './util.js';
15
- import { CustomTypeRegistry } from '@module/types/registry.js';
16
21
 
17
22
  export class WalStreamTestContext implements AsyncDisposable {
18
23
  private _walStream?: WalStream;
19
24
  private abortController = new AbortController();
20
- private streamPromise?: Promise<void>;
25
+ private syncRulesId?: number;
26
+ private syncRulesContent?: storage.PersistedSyncRulesContent;
21
27
  public storage?: SyncRulesBucketStorage;
22
- private replicationConnection?: pgwire.PgConnection;
23
- private snapshotPromise?: Promise<void>;
28
+ private settledReplicationPromise?: Promise<PromiseSettledResult<void>>;
24
29
 
25
30
  /**
26
31
  * Tests operating on the wal stream need to configure the stream and manage asynchronous
@@ -30,7 +35,7 @@ export class WalStreamTestContext implements AsyncDisposable {
30
35
  */
31
36
  static async open(
32
37
  factory: (options: storage.TestStorageOptions) => Promise<BucketStorageFactory>,
33
- options?: { doNotClear?: boolean; walStreamOptions?: Partial<WalStreamOptions> }
38
+ options?: { doNotClear?: boolean; storageVersion?: number; walStreamOptions?: Partial<WalStreamOptions> }
34
39
  ) {
35
40
  const f = await factory({ doNotClear: options?.doNotClear });
36
41
  const connectionManager = new PgManager(TEST_CONNECTION_OPTIONS, {});
@@ -39,13 +44,18 @@ export class WalStreamTestContext implements AsyncDisposable {
39
44
  await clearTestDb(connectionManager.pool);
40
45
  }
41
46
 
42
- return new WalStreamTestContext(f, connectionManager, options?.walStreamOptions);
47
+ const storageVersion = options?.storageVersion ?? LEGACY_STORAGE_VERSION;
48
+ const versionedBuckets = STORAGE_VERSION_CONFIG[storageVersion]?.versionedBuckets ?? false;
49
+
50
+ return new WalStreamTestContext(f, connectionManager, options?.walStreamOptions, storageVersion, versionedBuckets);
43
51
  }
44
52
 
45
53
  constructor(
46
54
  public factory: BucketStorageFactory,
47
55
  public connectionManager: PgManager,
48
- private walStreamOptions?: Partial<WalStreamOptions>
56
+ private walStreamOptions?: Partial<WalStreamOptions>,
57
+ private storageVersion: number = LEGACY_STORAGE_VERSION,
58
+ private versionedBuckets: boolean = STORAGE_VERSION_CONFIG[storageVersion]?.versionedBuckets ?? false
49
59
  ) {
50
60
  createCoreReplicationMetrics(METRICS_HELPER.metricsEngine);
51
61
  initializeCoreReplicationMetrics(METRICS_HELPER.metricsEngine);
@@ -55,21 +65,10 @@ export class WalStreamTestContext implements AsyncDisposable {
55
65
  await this.dispose();
56
66
  }
57
67
 
58
- /**
59
- * Clear any errors from startStream, to allow for a graceful dispose when streaming errors
60
- * were expected.
61
- */
62
- async clearStreamError() {
63
- if (this.streamPromise != null) {
64
- this.streamPromise = this.streamPromise.catch((e) => {});
65
- }
66
- }
67
-
68
68
  async dispose() {
69
69
  this.abortController.abort();
70
70
  try {
71
- await this.snapshotPromise;
72
- await this.streamPromise;
71
+ await this.settledReplicationPromise;
73
72
  await this.connectionManager.destroy();
74
73
  await this.factory?.[Symbol.asyncDispose]();
75
74
  } catch (e) {
@@ -95,7 +94,11 @@ export class WalStreamTestContext implements AsyncDisposable {
95
94
  }
96
95
 
97
96
  async updateSyncRules(content: string) {
98
- const syncRules = await this.factory.updateSyncRules({ content: content, validate: true });
97
+ const syncRules = await this.factory.updateSyncRules(
98
+ updateSyncRulesFromYaml(content, { validate: true, storageVersion: this.storageVersion })
99
+ );
100
+ this.syncRulesId = syncRules.id;
101
+ this.syncRulesContent = syncRules;
99
102
  this.storage = this.factory.getInstance(syncRules);
100
103
  return this.storage!;
101
104
  }
@@ -106,6 +109,8 @@ export class WalStreamTestContext implements AsyncDisposable {
106
109
  throw new Error(`Next sync rules not available`);
107
110
  }
108
111
 
112
+ this.syncRulesId = syncRules.id;
113
+ this.syncRulesContent = syncRules;
109
114
  this.storage = this.factory.getInstance(syncRules);
110
115
  return this.storage!;
111
116
  }
@@ -116,10 +121,19 @@ export class WalStreamTestContext implements AsyncDisposable {
116
121
  throw new Error(`Active sync rules not available`);
117
122
  }
118
123
 
124
+ this.syncRulesId = syncRules.id;
125
+ this.syncRulesContent = syncRules;
119
126
  this.storage = this.factory.getInstance(syncRules);
120
127
  return this.storage!;
121
128
  }
122
129
 
130
+ private getSyncRulesContent(): storage.PersistedSyncRulesContent {
131
+ if (this.syncRulesContent == null) {
132
+ throw new Error('Sync rules not configured - call updateSyncRules() first');
133
+ }
134
+ return this.syncRulesContent;
135
+ }
136
+
123
137
  get walStream() {
124
138
  if (this.storage == null) {
125
139
  throw new Error('updateSyncRules() first');
@@ -143,43 +157,46 @@ export class WalStreamTestContext implements AsyncDisposable {
143
157
  */
144
158
  async initializeReplication() {
145
159
  await this.replicateSnapshot();
146
- this.startStreaming();
147
160
  // Make sure we're up to date
148
161
  await this.getCheckpoint();
149
162
  }
150
163
 
164
+ /**
165
+ * Replicate the initial snapshot, and start streaming.
166
+ */
151
167
  async replicateSnapshot() {
152
- const promise = (async () => {
153
- this.replicationConnection = await this.connectionManager.replicationConnection();
154
- await this.walStream.initReplication(this.replicationConnection);
155
- })();
156
- this.snapshotPromise = promise.catch((e) => e);
157
- await promise;
158
- }
159
-
160
- startStreaming() {
161
- if (this.replicationConnection == null) {
162
- throw new Error('Call replicateSnapshot() before startStreaming()');
168
+ // Use a settledPromise to avoid unhandled rejections
169
+ this.settledReplicationPromise = settledPromise(this.walStream.replicate());
170
+ try {
171
+ await Promise.race([unsettledPromise(this.settledReplicationPromise), this.walStream.waitForInitialSnapshot()]);
172
+ } catch (e) {
173
+ if (e instanceof ReplicationAbortedError && e.cause != null) {
174
+ // Edge case for tests: replicate() can throw an error, but we'd receive the ReplicationAbortedError from
175
+ // waitForInitialSnapshot() first. In that case, prioritize the cause, e.g. MissingReplicationSlotError.
176
+ // This is not a concern for production use, since we only use waitForInitialSnapshot() in tests.
177
+ throw e.cause;
178
+ }
179
+ throw e;
163
180
  }
164
- this.streamPromise = this.walStream.streamChanges(this.replicationConnection!);
165
181
  }
166
182
 
167
183
  async getCheckpoint(options?: { timeout?: number }) {
168
184
  let checkpoint = await Promise.race([
169
185
  getClientCheckpoint(this.pool, this.factory, { timeout: options?.timeout ?? 15_000 }),
170
- this.streamPromise
186
+ unsettledPromise(this.settledReplicationPromise!)
171
187
  ]);
172
188
  if (checkpoint == null) {
173
- // This indicates an issue with the test setup - streamingPromise completed instead
189
+ // This indicates an issue with the test setup - replicationPromise completed instead
174
190
  // of getClientCheckpoint()
175
- throw new Error('Test failure - streamingPromise completed');
191
+ throw new Error('Test failure - replicationPromise completed');
176
192
  }
177
193
  return checkpoint;
178
194
  }
179
195
 
180
196
  async getBucketsDataBatch(buckets: Record<string, InternalOpId>, options?: { timeout?: number }) {
181
197
  let checkpoint = await this.getCheckpoint(options);
182
- const map = new Map<string, InternalOpId>(Object.entries(buckets));
198
+ const syncRules = this.getSyncRulesContent();
199
+ const map = Object.entries(buckets).map(([bucket, start]) => bucketRequest(syncRules, bucket, start));
183
200
  return test_utils.fromAsync(this.storage!.getBucketDataBatch(checkpoint, map));
184
201
  }
185
202
 
@@ -191,8 +208,9 @@ export class WalStreamTestContext implements AsyncDisposable {
191
208
  if (typeof start == 'string') {
192
209
  start = BigInt(start);
193
210
  }
211
+ const syncRules = this.getSyncRulesContent();
194
212
  const checkpoint = await this.getCheckpoint(options);
195
- const map = new Map<string, InternalOpId>([[bucket, start]]);
213
+ let map = [bucketRequest(syncRules, bucket, start)];
196
214
  let data: OplogEntry[] = [];
197
215
  while (true) {
198
216
  const batch = this.storage!.getBucketDataBatch(checkpoint, map);
@@ -202,11 +220,30 @@ export class WalStreamTestContext implements AsyncDisposable {
202
220
  if (batches.length == 0 || !batches[0]!.chunkData.has_more) {
203
221
  break;
204
222
  }
205
- map.set(bucket, BigInt(batches[0]!.chunkData.next_after));
223
+ map = [bucketRequest(syncRules, bucket, BigInt(batches[0]!.chunkData.next_after))];
206
224
  }
207
225
  return data;
208
226
  }
209
227
 
228
+ async getChecksums(buckets: string[], options?: { timeout?: number }) {
229
+ const checkpoint = await this.getCheckpoint(options);
230
+ const syncRules = this.getSyncRulesContent();
231
+ const versionedBuckets = buckets.map((bucket) => bucketRequest(syncRules, bucket, 0n));
232
+ const checksums = await this.storage!.getChecksums(checkpoint, versionedBuckets);
233
+
234
+ const unversioned = new Map();
235
+ for (let i = 0; i < buckets.length; i++) {
236
+ unversioned.set(buckets[i], checksums.get(versionedBuckets[i].bucket)!);
237
+ }
238
+
239
+ return unversioned;
240
+ }
241
+
242
+ async getChecksum(bucket: string, options?: { timeout?: number }) {
243
+ const checksums = await this.getChecksums([bucket], options);
244
+ return checksums.get(bucket);
245
+ }
246
+
210
247
  /**
211
248
  * This does not wait for a client checkpoint.
212
249
  */
@@ -215,8 +252,9 @@ export class WalStreamTestContext implements AsyncDisposable {
215
252
  if (typeof start == 'string') {
216
253
  start = BigInt(start);
217
254
  }
255
+ const syncRules = this.getSyncRulesContent();
218
256
  const { checkpoint } = await this.storage!.getCheckpoint();
219
- const map = new Map<string, InternalOpId>([[bucket, start]]);
257
+ const map = [bucketRequest(syncRules, bucket, start)];
220
258
  const batch = this.storage!.getBucketDataBatch(checkpoint, map);
221
259
  const batches = await test_utils.fromAsync(batch);
222
260
  return batches[0]?.chunkData.data ?? [];