@powersync/service-module-postgres 0.17.2 → 0.18.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,11 +1,10 @@
1
1
  import { MissingReplicationSlotError } from '@module/replication/WalStream.js';
2
- import { storage } from '@powersync/service-core';
3
2
  import { METRICS_HELPER, putOp, removeOp } from '@powersync/service-core-tests';
4
3
  import { pgwireRows } from '@powersync/service-jpgwire';
5
4
  import { ReplicationMetric } from '@powersync/service-types';
6
5
  import * as crypto from 'crypto';
7
- import { afterAll, beforeAll, describe, expect, test } from 'vitest';
8
- import { describeWithStorage } from './util.js';
6
+ import { describe, expect, test } from 'vitest';
7
+ import { describeWithStorage, StorageVersionTestContext } from './util.js';
9
8
  import { WalStreamTestContext, withMaxWalSize } from './wal_stream_utils.js';
10
9
  import { JSONBig } from '@powersync/service-jsonbig';
11
10
 
@@ -20,9 +19,12 @@ describe('wal stream', () => {
20
19
  describeWithStorage({ timeout: 20_000 }, defineWalStreamTests);
21
20
  });
22
21
 
23
- function defineWalStreamTests(factory: storage.TestStorageFactory) {
22
+ function defineWalStreamTests({ factory, storageVersion }: StorageVersionTestContext) {
23
+ const openContext = (options?: Parameters<typeof WalStreamTestContext.open>[1]) => {
24
+ return WalStreamTestContext.open(factory, { ...options, storageVersion });
25
+ };
24
26
  test('replicating basic values', async () => {
25
- await using context = await WalStreamTestContext.open(factory);
27
+ await using context = await openContext();
26
28
  const { pool } = context;
27
29
  await context.updateSyncRules(`
28
30
  bucket_definitions:
@@ -57,7 +59,7 @@ bucket_definitions:
57
59
  });
58
60
 
59
61
  test('replicating case sensitive table', async () => {
60
- await using context = await WalStreamTestContext.open(factory);
62
+ await using context = await openContext();
61
63
  const { pool } = context;
62
64
  await context.updateSyncRules(`
63
65
  bucket_definitions:
@@ -88,7 +90,7 @@ bucket_definitions:
88
90
  });
89
91
 
90
92
  test('replicating TOAST values', async () => {
91
- await using context = await WalStreamTestContext.open(factory);
93
+ await using context = await openContext();
92
94
  const { pool } = context;
93
95
  await context.updateSyncRules(`
94
96
  bucket_definitions:
@@ -126,7 +128,7 @@ bucket_definitions:
126
128
  });
127
129
 
128
130
  test('replicating TRUNCATE', async () => {
129
- await using context = await WalStreamTestContext.open(factory);
131
+ await using context = await openContext();
130
132
  const { pool } = context;
131
133
  const syncRuleContent = `
132
134
  bucket_definitions:
@@ -157,7 +159,7 @@ bucket_definitions:
157
159
  });
158
160
 
159
161
  test('replicating changing primary key', async () => {
160
- await using context = await WalStreamTestContext.open(factory);
162
+ await using context = await openContext();
161
163
  const { pool } = context;
162
164
  await context.updateSyncRules(BASIC_SYNC_RULES);
163
165
  await pool.query(`DROP TABLE IF EXISTS test_data`);
@@ -198,7 +200,7 @@ bucket_definitions:
198
200
  });
199
201
 
200
202
  test('initial sync', async () => {
201
- await using context = await WalStreamTestContext.open(factory);
203
+ await using context = await openContext();
202
204
  const { pool } = context;
203
205
  await context.updateSyncRules(BASIC_SYNC_RULES);
204
206
 
@@ -217,7 +219,7 @@ bucket_definitions:
217
219
  });
218
220
 
219
221
  test('record too large', async () => {
220
- await using context = await WalStreamTestContext.open(factory);
222
+ await using context = await openContext();
221
223
  await context.updateSyncRules(`bucket_definitions:
222
224
  global:
223
225
  data:
@@ -254,7 +256,7 @@ bucket_definitions:
254
256
  });
255
257
 
256
258
  test('table not in sync rules', async () => {
257
- await using context = await WalStreamTestContext.open(factory);
259
+ await using context = await openContext();
258
260
  const { pool } = context;
259
261
  await context.updateSyncRules(BASIC_SYNC_RULES);
260
262
 
@@ -280,7 +282,7 @@ bucket_definitions:
280
282
 
281
283
  test('reporting slot issues', async () => {
282
284
  {
283
- await using context = await WalStreamTestContext.open(factory);
285
+ await using context = await openContext();
284
286
  const { pool } = context;
285
287
  await context.updateSyncRules(`
286
288
  bucket_definitions:
@@ -310,7 +312,7 @@ bucket_definitions:
310
312
  }
311
313
 
312
314
  {
313
- await using context = await WalStreamTestContext.open(factory, { doNotClear: true });
315
+ await using context = await openContext({ doNotClear: true });
314
316
  const { pool } = context;
315
317
  await pool.query('DROP PUBLICATION powersync');
316
318
  await pool.query(`UPDATE test_data SET description = 'updated'`);
@@ -345,7 +347,7 @@ bucket_definitions:
345
347
 
346
348
  test('dropped replication slot', async () => {
347
349
  {
348
- await using context = await WalStreamTestContext.open(factory);
350
+ await using context = await openContext();
349
351
  const { pool } = context;
350
352
  await context.updateSyncRules(`
351
353
  bucket_definitions:
@@ -375,7 +377,7 @@ bucket_definitions:
375
377
  }
376
378
 
377
379
  {
378
- await using context = await WalStreamTestContext.open(factory, { doNotClear: true });
380
+ await using context = await openContext({ doNotClear: true });
379
381
  const { pool } = context;
380
382
  const storage = await context.factory.getActiveStorage();
381
383
 
@@ -396,7 +398,7 @@ bucket_definitions:
396
398
  });
397
399
 
398
400
  test('replication slot lost', async () => {
399
- await using baseContext = await WalStreamTestContext.open(factory, { doNotClear: true });
401
+ await using baseContext = await openContext({ doNotClear: true });
400
402
 
401
403
  const serverVersion = await baseContext.connectionManager.getServerVersion();
402
404
  if (serverVersion!.compareMain('13.0.0') < 0) {
@@ -408,7 +410,7 @@ bucket_definitions:
408
410
  await using s = await withMaxWalSize(baseContext.pool, '100MB');
409
411
 
410
412
  {
411
- await using context = await WalStreamTestContext.open(factory);
413
+ await using context = await openContext();
412
414
  const { pool } = context;
413
415
  await context.updateSyncRules(`
414
416
  bucket_definitions:
@@ -438,7 +440,7 @@ bucket_definitions:
438
440
  }
439
441
 
440
442
  {
441
- await using context = await WalStreamTestContext.open(factory, { doNotClear: true });
443
+ await using context = await openContext({ doNotClear: true });
442
444
  const { pool } = context;
443
445
  const storage = await context.factory.getActiveStorage();
444
446
  const slotName = storage?.slot_name!;
@@ -479,7 +481,7 @@ bucket_definitions:
479
481
  });
480
482
 
481
483
  test('old date format', async () => {
482
- await using context = await WalStreamTestContext.open(factory);
484
+ await using context = await openContext();
483
485
  await context.updateSyncRules(BASIC_SYNC_RULES);
484
486
 
485
487
  const { pool } = context;
@@ -494,7 +496,7 @@ bucket_definitions:
494
496
  });
495
497
 
496
498
  test('new date format', async () => {
497
- await using context = await WalStreamTestContext.open(factory);
499
+ await using context = await openContext();
498
500
  await context.updateSyncRules(`
499
501
  streams:
500
502
  stream:
@@ -515,7 +517,7 @@ config:
515
517
  });
516
518
 
517
519
  test('custom types', async () => {
518
- await using context = await WalStreamTestContext.open(factory);
520
+ await using context = await openContext();
519
521
 
520
522
  await context.updateSyncRules(`
521
523
  streams:
@@ -550,7 +552,7 @@ config:
550
552
  });
551
553
 
552
554
  test('custom types in primary key', async () => {
553
- await using context = await WalStreamTestContext.open(factory);
555
+ await using context = await openContext();
554
556
 
555
557
  await context.updateSyncRules(`
556
558
  streams:
@@ -576,7 +578,7 @@ config:
576
578
  test('replica identity handling', async () => {
577
579
  // This specifically test a case of timestamps being used as part of the replica identity.
578
580
  // There was a regression in versions 1.15.0-1.15.5, which this tests for.
579
- await using context = await WalStreamTestContext.open(factory);
581
+ await using context = await openContext();
580
582
  const { pool } = context;
581
583
  await context.updateSyncRules(BASIC_SYNC_RULES);
582
584
 
@@ -1,23 +1,27 @@
1
1
  import { PgManager } from '@module/replication/PgManager.js';
2
2
  import { PUBLICATION_NAME, WalStream, WalStreamOptions } from '@module/replication/WalStream.js';
3
+ import { CustomTypeRegistry } from '@module/types/registry.js';
3
4
  import {
4
5
  BucketStorageFactory,
5
6
  createCoreReplicationMetrics,
6
7
  initializeCoreReplicationMetrics,
7
8
  InternalOpId,
9
+ LEGACY_STORAGE_VERSION,
8
10
  OplogEntry,
11
+ STORAGE_VERSION_CONFIG,
9
12
  storage,
10
- SyncRulesBucketStorage
13
+ SyncRulesBucketStorage,
14
+ updateSyncRulesFromYaml
11
15
  } from '@powersync/service-core';
12
16
  import { METRICS_HELPER, test_utils } from '@powersync/service-core-tests';
13
17
  import * as pgwire from '@powersync/service-jpgwire';
14
18
  import { clearTestDb, getClientCheckpoint, TEST_CONNECTION_OPTIONS } from './util.js';
15
- import { CustomTypeRegistry } from '@module/types/registry.js';
16
19
 
17
20
  export class WalStreamTestContext implements AsyncDisposable {
18
21
  private _walStream?: WalStream;
19
22
  private abortController = new AbortController();
20
23
  private streamPromise?: Promise<void>;
24
+ private syncRulesId?: number;
21
25
  public storage?: SyncRulesBucketStorage;
22
26
  private replicationConnection?: pgwire.PgConnection;
23
27
  private snapshotPromise?: Promise<void>;
@@ -30,7 +34,7 @@ export class WalStreamTestContext implements AsyncDisposable {
30
34
  */
31
35
  static async open(
32
36
  factory: (options: storage.TestStorageOptions) => Promise<BucketStorageFactory>,
33
- options?: { doNotClear?: boolean; walStreamOptions?: Partial<WalStreamOptions> }
37
+ options?: { doNotClear?: boolean; storageVersion?: number; walStreamOptions?: Partial<WalStreamOptions> }
34
38
  ) {
35
39
  const f = await factory({ doNotClear: options?.doNotClear });
36
40
  const connectionManager = new PgManager(TEST_CONNECTION_OPTIONS, {});
@@ -39,13 +43,18 @@ export class WalStreamTestContext implements AsyncDisposable {
39
43
  await clearTestDb(connectionManager.pool);
40
44
  }
41
45
 
42
- return new WalStreamTestContext(f, connectionManager, options?.walStreamOptions);
46
+ const storageVersion = options?.storageVersion ?? LEGACY_STORAGE_VERSION;
47
+ const versionedBuckets = STORAGE_VERSION_CONFIG[storageVersion]?.versionedBuckets ?? false;
48
+
49
+ return new WalStreamTestContext(f, connectionManager, options?.walStreamOptions, storageVersion, versionedBuckets);
43
50
  }
44
51
 
45
52
  constructor(
46
53
  public factory: BucketStorageFactory,
47
54
  public connectionManager: PgManager,
48
- private walStreamOptions?: Partial<WalStreamOptions>
55
+ private walStreamOptions?: Partial<WalStreamOptions>,
56
+ private storageVersion: number = LEGACY_STORAGE_VERSION,
57
+ private versionedBuckets: boolean = STORAGE_VERSION_CONFIG[storageVersion]?.versionedBuckets ?? false
49
58
  ) {
50
59
  createCoreReplicationMetrics(METRICS_HELPER.metricsEngine);
51
60
  initializeCoreReplicationMetrics(METRICS_HELPER.metricsEngine);
@@ -95,7 +104,10 @@ export class WalStreamTestContext implements AsyncDisposable {
95
104
  }
96
105
 
97
106
  async updateSyncRules(content: string) {
98
- const syncRules = await this.factory.updateSyncRules({ content: content, validate: true });
107
+ const syncRules = await this.factory.updateSyncRules(
108
+ updateSyncRulesFromYaml(content, { validate: true, storageVersion: this.storageVersion })
109
+ );
110
+ this.syncRulesId = syncRules.id;
99
111
  this.storage = this.factory.getInstance(syncRules);
100
112
  return this.storage!;
101
113
  }
@@ -106,6 +118,7 @@ export class WalStreamTestContext implements AsyncDisposable {
106
118
  throw new Error(`Next sync rules not available`);
107
119
  }
108
120
 
121
+ this.syncRulesId = syncRules.id;
109
122
  this.storage = this.factory.getInstance(syncRules);
110
123
  return this.storage!;
111
124
  }
@@ -116,6 +129,7 @@ export class WalStreamTestContext implements AsyncDisposable {
116
129
  throw new Error(`Active sync rules not available`);
117
130
  }
118
131
 
132
+ this.syncRulesId = syncRules.id;
119
133
  this.storage = this.factory.getInstance(syncRules);
120
134
  return this.storage!;
121
135
  }
@@ -177,9 +191,21 @@ export class WalStreamTestContext implements AsyncDisposable {
177
191
  return checkpoint;
178
192
  }
179
193
 
194
+ private resolveBucketName(bucket: string) {
195
+ if (!this.versionedBuckets || /^\d+#/.test(bucket)) {
196
+ return bucket;
197
+ }
198
+ if (this.syncRulesId == null) {
199
+ throw new Error('Sync rules not configured - call updateSyncRules() first');
200
+ }
201
+ return `${this.syncRulesId}#${bucket}`;
202
+ }
203
+
180
204
  async getBucketsDataBatch(buckets: Record<string, InternalOpId>, options?: { timeout?: number }) {
181
205
  let checkpoint = await this.getCheckpoint(options);
182
- const map = new Map<string, InternalOpId>(Object.entries(buckets));
206
+ const map = new Map<string, InternalOpId>(
207
+ Object.entries(buckets).map(([bucket, opId]) => [this.resolveBucketName(bucket), opId])
208
+ );
183
209
  return test_utils.fromAsync(this.storage!.getBucketDataBatch(checkpoint, map));
184
210
  }
185
211
 
@@ -191,8 +217,9 @@ export class WalStreamTestContext implements AsyncDisposable {
191
217
  if (typeof start == 'string') {
192
218
  start = BigInt(start);
193
219
  }
220
+ const resolvedBucket = this.resolveBucketName(bucket);
194
221
  const checkpoint = await this.getCheckpoint(options);
195
- const map = new Map<string, InternalOpId>([[bucket, start]]);
222
+ const map = new Map<string, InternalOpId>([[resolvedBucket, start]]);
196
223
  let data: OplogEntry[] = [];
197
224
  while (true) {
198
225
  const batch = this.storage!.getBucketDataBatch(checkpoint, map);
@@ -202,11 +229,29 @@ export class WalStreamTestContext implements AsyncDisposable {
202
229
  if (batches.length == 0 || !batches[0]!.chunkData.has_more) {
203
230
  break;
204
231
  }
205
- map.set(bucket, BigInt(batches[0]!.chunkData.next_after));
232
+ map.set(resolvedBucket, BigInt(batches[0]!.chunkData.next_after));
206
233
  }
207
234
  return data;
208
235
  }
209
236
 
237
+ async getChecksums(buckets: string[], options?: { timeout?: number }) {
238
+ const checkpoint = await this.getCheckpoint(options);
239
+ const versionedBuckets = buckets.map((bucket) => this.resolveBucketName(bucket));
240
+ const checksums = await this.storage!.getChecksums(checkpoint, versionedBuckets);
241
+
242
+ const unversioned = new Map();
243
+ for (let i = 0; i < buckets.length; i++) {
244
+ unversioned.set(buckets[i], checksums.get(versionedBuckets[i])!);
245
+ }
246
+
247
+ return unversioned;
248
+ }
249
+
250
+ async getChecksum(bucket: string, options?: { timeout?: number }) {
251
+ const checksums = await this.getChecksums([bucket], options);
252
+ return checksums.get(bucket);
253
+ }
254
+
210
255
  /**
211
256
  * This does not wait for a client checkpoint.
212
257
  */
@@ -215,8 +260,9 @@ export class WalStreamTestContext implements AsyncDisposable {
215
260
  if (typeof start == 'string') {
216
261
  start = BigInt(start);
217
262
  }
263
+ const resolvedBucket = this.resolveBucketName(bucket);
218
264
  const { checkpoint } = await this.storage!.getCheckpoint();
219
- const map = new Map<string, InternalOpId>([[bucket, start]]);
265
+ const map = new Map<string, InternalOpId>([[resolvedBucket, start]]);
220
266
  const batch = this.storage!.getBucketDataBatch(checkpoint, map);
221
267
  const batches = await test_utils.fromAsync(batch);
222
268
  return batches[0]?.chunkData.data ?? [];