@powersync/service-module-postgres-storage 0.16.3 → 0.17.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.
- package/CHANGELOG.md +33 -0
- package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.d.ts +3 -0
- package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.js +42 -0
- package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.js.map +1 -0
- package/dist/storage/PostgresBucketStorageFactory.js +0 -2
- package/dist/storage/PostgresBucketStorageFactory.js.map +1 -1
- package/dist/storage/PostgresCompactor.d.ts +2 -0
- package/dist/storage/PostgresCompactor.js +20 -0
- package/dist/storage/PostgresCompactor.js.map +1 -1
- package/dist/storage/PostgresSyncRulesStorage.d.ts +16 -1
- package/dist/storage/PostgresSyncRulesStorage.js +200 -131
- package/dist/storage/PostgresSyncRulesStorage.js.map +1 -1
- package/dist/storage/batch/PostgresBucketBatch.d.ts +2 -20
- package/dist/storage/batch/PostgresBucketBatch.js +162 -151
- package/dist/storage/batch/PostgresBucketBatch.js.map +1 -1
- package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.d.ts +1 -1
- package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js +156 -36
- package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js.map +1 -1
- package/dist/storage/current-data-store.d.ts +8 -2
- package/dist/storage/current-data-store.js +66 -11
- package/dist/storage/current-data-store.js.map +1 -1
- package/dist/types/models/WriteCheckpoint.d.ts +2 -0
- package/dist/types/models/WriteCheckpoint.js +5 -2
- package/dist/types/models/WriteCheckpoint.js.map +1 -1
- package/dist/utils/checkpoints.d.ts +9 -0
- package/dist/utils/checkpoints.js +26 -0
- package/dist/utils/checkpoints.js.map +1 -0
- package/package.json +8 -8
- package/src/migrations/scripts/1782950400000-checkpoint-requested-at.ts +51 -0
- package/src/storage/PostgresBucketStorageFactory.ts +0 -3
- package/src/storage/PostgresCompactor.ts +24 -0
- package/src/storage/PostgresSyncRulesStorage.ts +219 -137
- package/src/storage/batch/PostgresBucketBatch.ts +169 -154
- package/src/storage/checkpoints/PostgresWriteCheckpointAPI.ts +165 -37
- package/src/storage/current-data-store.ts +66 -11
- package/src/types/models/WriteCheckpoint.ts +5 -2
- package/src/utils/checkpoints.ts +31 -0
- package/test/src/__snapshots__/storage_sync.test.ts.snap +0 -582
- package/test/src/checkpoint_notifications.test.ts +522 -0
- package/test/src/storage.test.ts +214 -5
- package/test/src/storage_compacting.test.ts +3 -3
- package/test/src/storage_sync.test.ts +4 -4
- package/test/tsconfig.json +1 -1
- package/tsconfig.tsbuildinfo +1 -1
package/test/src/storage.test.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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);
|
|
@@ -90,7 +90,7 @@ function registerStorageVersionTests(storageVersion: number) {
|
|
|
90
90
|
|
|
91
91
|
const options: storage.BucketDataBatchOptions = {};
|
|
92
92
|
|
|
93
|
-
const batch1 = await test_utils.
|
|
93
|
+
const batch1 = await test_utils.getBatchArray(
|
|
94
94
|
bucketStorage.getBucketDataBatch(test_utils.testCheckpoint(checkpoint), [globalBucket], options)
|
|
95
95
|
);
|
|
96
96
|
expect(test_utils.getBatchData(batch1)).toEqual([
|
|
@@ -102,7 +102,7 @@ function registerStorageVersionTests(storageVersion: number) {
|
|
|
102
102
|
next_after: '1'
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
-
const batch2 = await test_utils.
|
|
105
|
+
const batch2 = await test_utils.getBatchArray(
|
|
106
106
|
bucketStorage.getBucketDataBatch(
|
|
107
107
|
test_utils.testCheckpoint(checkpoint),
|
|
108
108
|
[{ ...globalBucket, start: BigInt(batch1[0].chunkData.next_after) }],
|
|
@@ -118,7 +118,7 @@ function registerStorageVersionTests(storageVersion: number) {
|
|
|
118
118
|
next_after: '2'
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
-
const batch3 = await test_utils.
|
|
121
|
+
const batch3 = await test_utils.getBatchArray(
|
|
122
122
|
bucketStorage.getBucketDataBatch(
|
|
123
123
|
test_utils.testCheckpoint(checkpoint),
|
|
124
124
|
[{ ...globalBucket, start: BigInt(batch2[0].chunkData.next_after) }],
|
|
@@ -134,7 +134,7 @@ function registerStorageVersionTests(storageVersion: number) {
|
|
|
134
134
|
next_after: '3'
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
-
const batch4 = await test_utils.
|
|
137
|
+
const batch4 = await test_utils.getBatchArray(
|
|
138
138
|
bucketStorage.getBucketDataBatch(
|
|
139
139
|
test_utils.testCheckpoint(checkpoint),
|
|
140
140
|
[{ ...globalBucket, start: BigInt(batch3[0].chunkData.next_after) }],
|