@powersync/service-module-postgres-storage 0.16.3 → 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.
Files changed (52) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.d.ts +3 -0
  3. package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.js +42 -0
  4. package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.js.map +1 -0
  5. package/dist/migrations/scripts/1784900000000-source-metadata.d.ts +3 -0
  6. package/dist/migrations/scripts/1784900000000-source-metadata.js +22 -0
  7. package/dist/migrations/scripts/1784900000000-source-metadata.js.map +1 -0
  8. package/dist/storage/PostgresBucketStorageFactory.js +0 -2
  9. package/dist/storage/PostgresBucketStorageFactory.js.map +1 -1
  10. package/dist/storage/PostgresCompactor.d.ts +2 -0
  11. package/dist/storage/PostgresCompactor.js +20 -0
  12. package/dist/storage/PostgresCompactor.js.map +1 -1
  13. package/dist/storage/PostgresSyncRulesStorage.d.ts +16 -1
  14. package/dist/storage/PostgresSyncRulesStorage.js +200 -131
  15. package/dist/storage/PostgresSyncRulesStorage.js.map +1 -1
  16. package/dist/storage/batch/PostgresBucketBatch.d.ts +2 -20
  17. package/dist/storage/batch/PostgresBucketBatch.js +217 -240
  18. package/dist/storage/batch/PostgresBucketBatch.js.map +1 -1
  19. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.d.ts +1 -1
  20. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js +156 -36
  21. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js.map +1 -1
  22. package/dist/storage/current-data-store.d.ts +8 -2
  23. package/dist/storage/current-data-store.js +66 -11
  24. package/dist/storage/current-data-store.js.map +1 -1
  25. package/dist/types/models/SourceTable.d.ts +7 -2
  26. package/dist/types/models/SourceTable.js +6 -2
  27. package/dist/types/models/SourceTable.js.map +1 -1
  28. package/dist/types/models/WriteCheckpoint.d.ts +2 -0
  29. package/dist/types/models/WriteCheckpoint.js +5 -2
  30. package/dist/types/models/WriteCheckpoint.js.map +1 -1
  31. package/dist/utils/checkpoints.d.ts +9 -0
  32. package/dist/utils/checkpoints.js +26 -0
  33. package/dist/utils/checkpoints.js.map +1 -0
  34. package/package.json +8 -8
  35. package/src/migrations/scripts/1782950400000-checkpoint-requested-at.ts +51 -0
  36. package/src/migrations/scripts/1784900000000-source-metadata.ts +31 -0
  37. package/src/storage/PostgresBucketStorageFactory.ts +0 -3
  38. package/src/storage/PostgresCompactor.ts +24 -0
  39. package/src/storage/PostgresSyncRulesStorage.ts +219 -137
  40. package/src/storage/batch/PostgresBucketBatch.ts +227 -246
  41. package/src/storage/checkpoints/PostgresWriteCheckpointAPI.ts +165 -37
  42. package/src/storage/current-data-store.ts +66 -11
  43. package/src/types/models/SourceTable.ts +7 -2
  44. package/src/types/models/WriteCheckpoint.ts +5 -2
  45. package/src/utils/checkpoints.ts +31 -0
  46. package/test/src/__snapshots__/storage_sync.test.ts.snap +0 -582
  47. package/test/src/checkpoint_notifications.test.ts +522 -0
  48. package/test/src/storage.test.ts +214 -5
  49. package/test/src/storage_compacting.test.ts +3 -3
  50. package/test/src/storage_sync.test.ts +48 -4
  51. package/test/tsconfig.json +1 -1
  52. package/tsconfig.tsbuildinfo +1 -1
@@ -1,8 +1,14 @@
1
- import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
1
+ import { framework, storage, updateSyncRulesFromYaml } from '@powersync/service-core';
2
2
  import { bucketRequest, register, test_utils } from '@powersync/service-core-tests';
3
+ import * as t from 'ts-codec';
3
4
  import { describe, expect, test } from 'vitest';
5
+ import { CLEAR_BATCH_LIMIT } from '../../src/storage/PostgresSyncRulesStorage.js';
4
6
  import { POSTGRES_STORAGE_FACTORY, TEST_STORAGE_VERSIONS } from './util.js';
5
7
 
8
+ const CheckpointRequestedAtRow = t.object({
9
+ checkpoint_requested_at: t.Null.or(framework.codecs.date)
10
+ });
11
+
6
12
  describe('Sync Bucket Validation', register.registerBucketValidationTests);
7
13
 
8
14
  for (let storageVersion of TEST_STORAGE_VERSIONS) {
@@ -20,6 +26,209 @@ for (let storageVersion of TEST_STORAGE_VERSIONS) {
20
26
  register.registerDataStorageCheckpointTests({ ...POSTGRES_STORAGE_FACTORY, storageVersion }));
21
27
 
22
28
  describe(`Postgres Sync Bucket Storage - pg-specific - v${storageVersion}`, () => {
29
+ test('uses checkpoint_requested_at as the client-requested checkpoint marker', async () => {
30
+ await using factory = await POSTGRES_STORAGE_FACTORY.factory();
31
+ const syncRules = await factory.updateSyncRules(
32
+ updateSyncRulesFromYaml(
33
+ `
34
+ bucket_definitions:
35
+ global:
36
+ data: []
37
+ `,
38
+ { storageVersion }
39
+ )
40
+ );
41
+ const bucketStorage = factory.getInstance(syncRules);
42
+ const requestedAt = async (userId = 'user1') =>
43
+ (
44
+ await factory.db.sql`
45
+ SELECT
46
+ checkpoint_requested_at
47
+ FROM
48
+ write_checkpoints
49
+ WHERE
50
+ user_id = ${{ type: 'varchar', value: userId }}
51
+ `
52
+ .decoded(CheckpointRequestedAtRow)
53
+ .first()
54
+ )?.checkpoint_requested_at;
55
+
56
+ await bucketStorage.createManagedWriteCheckpoints([
57
+ { user_id: 'user1', heads: { '1': '5/0' }, checkpoint_request_id: 42n }
58
+ ]);
59
+ const requested = await requestedAt();
60
+ expect(requested).toBeInstanceOf(Date);
61
+
62
+ await bucketStorage.createManagedWriteCheckpoints([
63
+ { user_id: 'user1', heads: { '1': '6/0' }, checkpoint_request_id: 41n }
64
+ ]);
65
+ await expect(requestedAt()).resolves.toEqual(requested);
66
+
67
+ const expiredRequestedAt = new Date('2024-01-01T00:00:00.000Z');
68
+ await factory.db.sql`
69
+ UPDATE write_checkpoints
70
+ SET
71
+ checkpoint_requested_at = ${{ type: 1184, value: expiredRequestedAt.toISOString() }}
72
+ WHERE
73
+ user_id = 'user1'
74
+ `.execute();
75
+ await bucketStorage.createManagedWriteCheckpoints([
76
+ { user_id: 'user1', heads: { '1': '6/0' }, checkpoint_request_id: 42n }
77
+ ]);
78
+ // Retrying the current id refreshes its retention timestamp. The shared
79
+ // checkpoint tests verify that the original source head is preserved.
80
+ expect((await requestedAt())!.getTime()).toBeGreaterThan(expiredRequestedAt.getTime());
81
+
82
+ await factory.db.sql`
83
+ UPDATE write_checkpoints
84
+ SET
85
+ checkpoint_requested_at = ${{ type: 1184, value: expiredRequestedAt.toISOString() }}
86
+ WHERE
87
+ user_id = 'user1'
88
+ `.execute();
89
+ await bucketStorage.createManagedWriteCheckpoints([
90
+ { user_id: 'user1', heads: { '1': '6/0' }, checkpoint_request_id: 43n }
91
+ ]);
92
+ // A greater id refreshes retention while advancing the stored checkpoint.
93
+ expect((await requestedAt())!.getTime()).toBeGreaterThan(expiredRequestedAt.getTime());
94
+
95
+ await bucketStorage.createManagedWriteCheckpoints([{ user_id: 'user1', heads: { '1': '7/0' } }]);
96
+ await expect(requestedAt()).resolves.toBeNull();
97
+
98
+ await bucketStorage.createManagedWriteCheckpoints([
99
+ { user_id: 'user2', heads: { '1': '8/0' }, checkpoint_request_id: 50n }
100
+ ]);
101
+ await factory.db.sql`
102
+ UPDATE write_checkpoints
103
+ SET
104
+ checkpoint_requested_at = ${{ type: 1184, value: '2024-01-01T00:00:00.000Z' }}
105
+ WHERE
106
+ user_id = 'user2'
107
+ `.execute();
108
+ await bucketStorage.compact({
109
+ compactBuckets: [],
110
+ deleteCheckpointRequestsBefore: new Date('2024-02-01T00:00:00.000Z')
111
+ });
112
+ // Compaction removes expired requests based on the refreshed timestamp.
113
+ await expect(requestedAt('user2')).resolves.toBeUndefined();
114
+
115
+ const customRequestedAt = async (userId = 'custom1') =>
116
+ (
117
+ await factory.db.sql`
118
+ SELECT
119
+ checkpoint_requested_at
120
+ FROM
121
+ custom_write_checkpoints
122
+ WHERE
123
+ user_id = ${{ type: 'varchar', value: userId }}
124
+ `
125
+ .decoded(CheckpointRequestedAtRow)
126
+ .first()
127
+ )?.checkpoint_requested_at;
128
+
129
+ bucketStorage.setWriteCheckpointMode(storage.WriteCheckpointMode.CUSTOM);
130
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
131
+ await writer.markAllSnapshotDone('1/1');
132
+ const customCheckpointRequestedAt = new Date('2024-01-01T00:00:00.000Z');
133
+ writer.addCustomWriteCheckpoint({
134
+ user_id: 'custom1',
135
+ checkpoint: 51n,
136
+ checkpoint_requested_at: customCheckpointRequestedAt
137
+ });
138
+ await writer.flush();
139
+ await expect(customRequestedAt()).resolves.toEqual(customCheckpointRequestedAt);
140
+
141
+ writer.addCustomWriteCheckpoint({
142
+ user_id: 'custom1',
143
+ checkpoint: 52n
144
+ });
145
+ await writer.flush();
146
+ await expect(customRequestedAt()).resolves.toBeNull();
147
+ });
148
+
149
+ test('clears storage in batches', async () => {
150
+ await using factory = await POSTGRES_STORAGE_FACTORY.factory();
151
+ const syncRules = await factory.updateSyncRules(
152
+ updateSyncRulesFromYaml(
153
+ `
154
+ bucket_definitions:
155
+ global:
156
+ data: []
157
+ `,
158
+ { storageVersion }
159
+ )
160
+ );
161
+ const bucketStorage = factory.getInstance(syncRules);
162
+ const groupId = bucketStorage.replicationStreamId;
163
+ const otherGroupId = groupId + 1;
164
+ const currentDataTable = bucketStorage.storageConfig.softDeleteCurrentData ? 'v3_current_data' : 'current_data';
165
+
166
+ // Seed more than one batch of bucket data, plus rows in the other tables,
167
+ // directly - clear() only depends on group_id.
168
+ const seed = async (gid: number, bucketDataRows: number) => {
169
+ await factory.db.query({
170
+ statement: `
171
+ INSERT INTO bucket_data (group_id, bucket_name, op_id, op, checksum)
172
+ SELECT $1, 'global[]', i, 'PUT', 0 FROM generate_series(1, $2) i
173
+ `,
174
+ params: [
175
+ { type: 'int4', value: gid },
176
+ { type: 'int4', value: bucketDataRows }
177
+ ]
178
+ });
179
+ await factory.db.query({
180
+ statement: `
181
+ INSERT INTO bucket_parameters (group_id, source_table, source_key, lookup, bucket_parameters)
182
+ SELECT $1, 'test', int4send(i), ''::bytea, '[]' FROM generate_series(1, 10) i
183
+ `,
184
+ params: [{ type: 'int4', value: gid }]
185
+ });
186
+ await factory.db.query({
187
+ statement: `
188
+ INSERT INTO ${currentDataTable} (group_id, source_table, source_key, buckets, data, lookups)
189
+ SELECT $1, 'test', int4send(i), '[]', ''::bytea, '{}' FROM generate_series(1, 10) i
190
+ `,
191
+ params: [{ type: 'int4', value: gid }]
192
+ });
193
+ await factory.db.query({
194
+ statement: `
195
+ INSERT INTO source_tables (id, group_id, connection_id, schema_name, table_name)
196
+ VALUES ($2, $1, 1, 'public', 'test')
197
+ `,
198
+ params: [
199
+ { type: 'int4', value: gid },
200
+ { type: 'varchar', value: `test-${gid}` }
201
+ ]
202
+ });
203
+ };
204
+ await seed(groupId, CLEAR_BATCH_LIMIT + 100);
205
+ await seed(otherGroupId, 10);
206
+
207
+ const countRows = async (table: string, gid: number) => {
208
+ const [row] = await factory.db.queryRows<{ count: bigint }>({
209
+ statement: `SELECT COUNT(*) AS count FROM ${table} WHERE group_id = $1`,
210
+ params: [{ type: 'int4', value: gid }]
211
+ });
212
+ return Number(row.count);
213
+ };
214
+
215
+ await bucketStorage.clear();
216
+
217
+ for (const table of ['bucket_data', 'bucket_parameters', currentDataTable, 'source_tables']) {
218
+ expect(await countRows(table, groupId), table).toEqual(0);
219
+ }
220
+ // Rows of other groups are not affected.
221
+ expect(await countRows('bucket_data', otherGroupId)).toEqual(10);
222
+ expect(await countRows('bucket_parameters', otherGroupId)).toEqual(10);
223
+ expect(await countRows(currentDataTable, otherGroupId)).toEqual(10);
224
+ expect(await countRows('source_tables', otherGroupId)).toEqual(1);
225
+
226
+ // An aborted signal stops the operation.
227
+ await expect(bucketStorage.clear({ signal: AbortSignal.abort() })).rejects.toThrow(
228
+ framework.ReplicationAbortedError
229
+ );
230
+ });
231
+
23
232
  /**
24
233
  * The split of returned results can vary depending on storage drivers.
25
234
  * The large rows here are 2MB large while the default chunk limit is 1mb.
@@ -99,7 +308,7 @@ for (let storageVersion of TEST_STORAGE_VERSIONS) {
99
308
 
100
309
  const options: storage.BucketDataBatchOptions = {};
101
310
 
102
- const batch1 = await test_utils.fromAsync(
311
+ const batch1 = await test_utils.getBatchArray(
103
312
  bucketStorage.getBucketDataBatch(test_utils.testCheckpoint(checkpoint), [globalBucket], options)
104
313
  );
105
314
  expect(test_utils.getBatchData(batch1)).toEqual([
@@ -111,7 +320,7 @@ for (let storageVersion of TEST_STORAGE_VERSIONS) {
111
320
  next_after: '1'
112
321
  });
113
322
 
114
- const batch2 = await test_utils.fromAsync(
323
+ const batch2 = await test_utils.getBatchArray(
115
324
  bucketStorage.getBucketDataBatch(
116
325
  test_utils.testCheckpoint(checkpoint),
117
326
  [{ ...globalBucket, start: BigInt(batch1[0].chunkData.next_after) }],
@@ -127,7 +336,7 @@ for (let storageVersion of TEST_STORAGE_VERSIONS) {
127
336
  next_after: '2'
128
337
  });
129
338
 
130
- const batch3 = await test_utils.fromAsync(
339
+ const batch3 = await test_utils.getBatchArray(
131
340
  bucketStorage.getBucketDataBatch(
132
341
  test_utils.testCheckpoint(checkpoint),
133
342
  [{ ...globalBucket, start: BigInt(batch2[0].chunkData.next_after) }],
@@ -143,7 +352,7 @@ for (let storageVersion of TEST_STORAGE_VERSIONS) {
143
352
  next_after: '3'
144
353
  });
145
354
 
146
- const batch4 = await test_utils.fromAsync(
355
+ const batch4 = await test_utils.getBatchArray(
147
356
  bucketStorage.getBucketDataBatch(
148
357
  test_utils.testCheckpoint(checkpoint),
149
358
  [{ ...globalBucket, start: BigInt(batch3[0].chunkData.next_after) }],
@@ -49,7 +49,7 @@ bucket_definitions:
49
49
  minBucketChanges: 1
50
50
  });
51
51
 
52
- const batch = await test_utils.oneFromAsync(
52
+ const batch = await test_utils.getSingleBatchItem(
53
53
  bucketStorage.getBucketDataBatch(test_utils.testCheckpoint(checkpoint), [
54
54
  bucketRequest(syncRulesContent, 'global[]', 0n)
55
55
  ])
@@ -110,7 +110,7 @@ bucket_definitions:
110
110
  })();
111
111
 
112
112
  const checkpoint = result!.flushed_op;
113
- const rowsBefore = await test_utils.oneFromAsync(
113
+ const rowsBefore = await test_utils.getSingleBatchItem(
114
114
  bucketStorage.getBucketDataBatch(test_utils.testCheckpoint(checkpoint), [request])
115
115
  );
116
116
  const dataBefore = test_utils.getBatchData(rowsBefore);
@@ -123,7 +123,7 @@ bucket_definitions:
123
123
  );
124
124
 
125
125
  // The method wraps in a transaction; on assertion error the bucket must remain unchanged.
126
- const rowsAfter = await test_utils.oneFromAsync(
126
+ const rowsAfter = await test_utils.getSingleBatchItem(
127
127
  bucketStorage.getBucketDataBatch(test_utils.testCheckpoint(checkpoint), [request])
128
128
  );
129
129
  expect(test_utils.getBatchData(rowsAfter)).toEqual(dataBefore);
@@ -17,6 +17,50 @@ function registerStorageVersionTests(storageVersion: number) {
17
17
  tableIdStrings: storageFactory.tableIdStrings
18
18
  });
19
19
 
20
+ test('updates source metadata on an existing resolved table', async () => {
21
+ await using factory = await storageFactory.factory();
22
+ const syncRules = await factory.updateSyncRules(
23
+ updateSyncRulesFromYaml(
24
+ `
25
+ bucket_definitions:
26
+ global:
27
+ data:
28
+ - SELECT id FROM test
29
+ `,
30
+ { storageVersion }
31
+ )
32
+ );
33
+ const bucketStorage = factory.getInstance(syncRules);
34
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
35
+ const source: storage.SourceEntityDescriptor = {
36
+ connectionTag: storage.SourceTable.DEFAULT_TAG,
37
+ objectId: 'test',
38
+ schema: 'public',
39
+ name: 'test',
40
+ replicaIdColumns: [{ name: 'id', type: 'VARCHAR', typeId: 25 }]
41
+ };
42
+
43
+ const initial = await writer.resolveTables({ connection_id: 1, source });
44
+ expect(initial.tables[0].sourceMetadata).toBeNull();
45
+
46
+ const sourceMetadata = { captureTableObjectId: 42 };
47
+ const updated = await writer.resolveTables({
48
+ connection_id: 1,
49
+ source,
50
+ reconcileSourceTables: ({ candidates }) => ({
51
+ compatibleTables: candidates.map((candidate) => candidate.withSourceMetadata(sourceMetadata)),
52
+ incompatibleTables: [],
53
+ newTableValues: { sourceMetadata }
54
+ })
55
+ });
56
+
57
+ expect(updated.tables.map((table) => table.id.toString())).toEqual(
58
+ initial.tables.map((table) => table.id.toString())
59
+ );
60
+ expect(updated.tables[0].sourceMetadata).toEqual(sourceMetadata);
61
+ expect((await writer.getSourceTableStatus(updated.tables[0]))?.sourceMetadata).toEqual(sourceMetadata);
62
+ });
63
+
20
64
  test('large batch (2)', async () => {
21
65
  // Test syncing a batch of data that is small in count,
22
66
  // but large enough in size to be split over multiple returned chunks.
@@ -90,7 +134,7 @@ function registerStorageVersionTests(storageVersion: number) {
90
134
 
91
135
  const options: storage.BucketDataBatchOptions = {};
92
136
 
93
- const batch1 = await test_utils.fromAsync(
137
+ const batch1 = await test_utils.getBatchArray(
94
138
  bucketStorage.getBucketDataBatch(test_utils.testCheckpoint(checkpoint), [globalBucket], options)
95
139
  );
96
140
  expect(test_utils.getBatchData(batch1)).toEqual([
@@ -102,7 +146,7 @@ function registerStorageVersionTests(storageVersion: number) {
102
146
  next_after: '1'
103
147
  });
104
148
 
105
- const batch2 = await test_utils.fromAsync(
149
+ const batch2 = await test_utils.getBatchArray(
106
150
  bucketStorage.getBucketDataBatch(
107
151
  test_utils.testCheckpoint(checkpoint),
108
152
  [{ ...globalBucket, start: BigInt(batch1[0].chunkData.next_after) }],
@@ -118,7 +162,7 @@ function registerStorageVersionTests(storageVersion: number) {
118
162
  next_after: '2'
119
163
  });
120
164
 
121
- const batch3 = await test_utils.fromAsync(
165
+ const batch3 = await test_utils.getBatchArray(
122
166
  bucketStorage.getBucketDataBatch(
123
167
  test_utils.testCheckpoint(checkpoint),
124
168
  [{ ...globalBucket, start: BigInt(batch2[0].chunkData.next_after) }],
@@ -134,7 +178,7 @@ function registerStorageVersionTests(storageVersion: number) {
134
178
  next_after: '3'
135
179
  });
136
180
 
137
- const batch4 = await test_utils.fromAsync(
181
+ const batch4 = await test_utils.getBatchArray(
138
182
  bucketStorage.getBucketDataBatch(
139
183
  test_utils.testCheckpoint(checkpoint),
140
184
  [{ ...globalBucket, start: BigInt(batch3[0].chunkData.next_after) }],
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "extends": "../../../tsconfig.tests.json",
3
3
  "compilerOptions": {
4
- "lib": ["ES2022", "esnext.disposable"],
4
+ "lib": ["ES2024", "esnext.disposable"],
5
5
  "rootDir": "src"
6
6
  },
7
7
  "include": ["src"],