@powersync/service-module-postgres-storage 0.17.0 → 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.
- package/CHANGELOG.md +27 -0
- package/dist/migrations/scripts/1784900000000-source-metadata.d.ts +3 -0
- package/dist/migrations/scripts/1784900000000-source-metadata.js +22 -0
- package/dist/migrations/scripts/1784900000000-source-metadata.js.map +1 -0
- package/dist/storage/batch/PostgresBucketBatch.js +55 -89
- package/dist/storage/batch/PostgresBucketBatch.js.map +1 -1
- package/dist/types/models/SourceTable.d.ts +7 -2
- package/dist/types/models/SourceTable.js +6 -2
- package/dist/types/models/SourceTable.js.map +1 -1
- package/package.json +3 -3
- package/src/migrations/scripts/1784900000000-source-metadata.ts +31 -0
- package/src/storage/batch/PostgresBucketBatch.ts +58 -92
- package/src/types/models/SourceTable.ts +7 -2
- package/test/src/storage_sync.test.ts +44 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -144,6 +144,7 @@ export class PostgresBucketBatch
|
|
|
144
144
|
|
|
145
145
|
async resolveTables(options: storage.ResolveTablesOptions): Promise<storage.ResolveTablesResult> {
|
|
146
146
|
const syncRules = options.parsedSyncConfig?.hydratedSyncConfig ?? this.sync_rules;
|
|
147
|
+
const reconcile = options.reconcileSourceTables ?? storage.defaultSourceTableReconciler;
|
|
147
148
|
const { connection_id, source } = options;
|
|
148
149
|
const { schema, name: table, objectId, replicaIdColumns, connectionTag, sendsCompleteRows } = source;
|
|
149
150
|
|
|
@@ -154,9 +155,10 @@ export class PostgresBucketBatch
|
|
|
154
155
|
}));
|
|
155
156
|
|
|
156
157
|
return this.db.transaction(async (db) => {
|
|
157
|
-
|
|
158
|
+
// Find records that overlap by name or relation id.
|
|
159
|
+
let candidateRows: SourceTableDecoded[];
|
|
158
160
|
if (objectId != null) {
|
|
159
|
-
|
|
161
|
+
candidateRows = await db.sql`
|
|
160
162
|
SELECT
|
|
161
163
|
*
|
|
162
164
|
FROM
|
|
@@ -164,15 +166,18 @@ export class PostgresBucketBatch
|
|
|
164
166
|
WHERE
|
|
165
167
|
group_id = ${{ type: 'int4', value: this.group_id }}
|
|
166
168
|
AND connection_id = ${{ type: 'int4', value: connection_id }}
|
|
167
|
-
AND
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
AND (
|
|
170
|
+
relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
|
|
171
|
+
OR (
|
|
172
|
+
schema_name = ${{ type: 'varchar', value: schema }}
|
|
173
|
+
AND table_name = ${{ type: 'varchar', value: table }}
|
|
174
|
+
)
|
|
175
|
+
)
|
|
171
176
|
`
|
|
172
177
|
.decoded(models.SourceTable)
|
|
173
|
-
.
|
|
178
|
+
.rows();
|
|
174
179
|
} else {
|
|
175
|
-
|
|
180
|
+
candidateRows = await db.sql`
|
|
176
181
|
SELECT
|
|
177
182
|
*
|
|
178
183
|
FROM
|
|
@@ -182,15 +187,42 @@ export class PostgresBucketBatch
|
|
|
182
187
|
AND connection_id = ${{ type: 'int4', value: connection_id }}
|
|
183
188
|
AND schema_name = ${{ type: 'varchar', value: schema }}
|
|
184
189
|
AND table_name = ${{ type: 'varchar', value: table }}
|
|
185
|
-
AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
|
|
186
190
|
`
|
|
187
191
|
.decoded(models.SourceTable)
|
|
188
|
-
.
|
|
192
|
+
.rows();
|
|
189
193
|
}
|
|
190
194
|
|
|
191
|
-
|
|
195
|
+
const candidateTables = candidateRows.map((row) => {
|
|
196
|
+
const table = this.sourceTableFromRow(row, connectionTag, syncRules);
|
|
197
|
+
table.storeCurrentData = sendsCompleteRows !== true;
|
|
198
|
+
return table;
|
|
199
|
+
});
|
|
200
|
+
const candidates = candidateTables.map((table) => table.clone());
|
|
201
|
+
const resolution = await reconcile({ source, candidates });
|
|
202
|
+
storage.validateSourceTableCandidateResolution(candidates, resolution);
|
|
203
|
+
|
|
204
|
+
for (const { id, sourceMetadata } of storage.diffSourceTableUpdates(candidateTables, resolution)) {
|
|
205
|
+
await db.sql`
|
|
206
|
+
UPDATE source_tables
|
|
207
|
+
SET
|
|
208
|
+
source_metadata = ${{ type: 'jsonb', value: sourceMetadata ?? null }}
|
|
209
|
+
WHERE
|
|
210
|
+
id = ${{ type: 'varchar', value: id.toString() }}
|
|
211
|
+
`.execute();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const materializedResolution = storage.materializeSourceTableResolution(candidateTables, resolution);
|
|
215
|
+
const compatibleTables = materializedResolution.compatibleTables;
|
|
216
|
+
|
|
217
|
+
// Keep one record, preferring one that has already been snapshotted.
|
|
218
|
+
const reuse = compatibleTables.find((candidate) => candidate.snapshotComplete) ?? compatibleTables[0] ?? null;
|
|
219
|
+
|
|
220
|
+
let sourceTable: storage.SourceTable;
|
|
221
|
+
if (reuse != null) {
|
|
222
|
+
sourceTable = reuse;
|
|
223
|
+
} else {
|
|
192
224
|
const id = options.idGenerator ? postgresTableId(options.idGenerator()) : uuid.v4();
|
|
193
|
-
|
|
225
|
+
const insertedRow = await db.sql`
|
|
194
226
|
INSERT INTO
|
|
195
227
|
source_tables (
|
|
196
228
|
id,
|
|
@@ -199,7 +231,8 @@ export class PostgresBucketBatch
|
|
|
199
231
|
relation_id,
|
|
200
232
|
schema_name,
|
|
201
233
|
table_name,
|
|
202
|
-
replica_id_columns
|
|
234
|
+
replica_id_columns,
|
|
235
|
+
source_metadata
|
|
203
236
|
)
|
|
204
237
|
VALUES
|
|
205
238
|
(
|
|
@@ -209,95 +242,27 @@ export class PostgresBucketBatch
|
|
|
209
242
|
${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }},
|
|
210
243
|
${{ type: 'varchar', value: schema }},
|
|
211
244
|
${{ type: 'varchar', value: table }},
|
|
212
|
-
${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
|
|
245
|
+
${{ type: 'jsonb', value: normalizedReplicaIdColumns }},
|
|
246
|
+
${{ type: 'jsonb', value: resolution.newTableValues.sourceMetadata ?? null }}
|
|
213
247
|
)
|
|
214
248
|
RETURNING
|
|
215
249
|
*
|
|
216
250
|
`
|
|
217
251
|
.decoded(models.SourceTable)
|
|
218
252
|
.first();
|
|
253
|
+
sourceTable = this.sourceTableFromRow(insertedRow!, connectionTag, syncRules);
|
|
254
|
+
sourceTable.storeCurrentData = sendsCompleteRows !== true;
|
|
219
255
|
}
|
|
220
256
|
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
snapshotComplete: sourceTableRow!.snapshot_done ?? true,
|
|
227
|
-
...syncRules.getMatchingSources(source)
|
|
228
|
-
});
|
|
229
|
-
if (!sourceTable.snapshotComplete) {
|
|
230
|
-
sourceTable.snapshotStatus = {
|
|
231
|
-
totalEstimatedCount: Number(sourceTableRow!.snapshot_total_estimated_count ?? -1n),
|
|
232
|
-
replicatedCount: Number(sourceTableRow!.snapshot_replicated_count ?? 0n),
|
|
233
|
-
lastKey: sourceTableRow!.snapshot_last_key
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
sourceTable.syncEvent = syncRules.tableTriggersEvent(source);
|
|
237
|
-
sourceTable.syncData = sourceTable.bucketDataSources.length > 0;
|
|
238
|
-
sourceTable.syncParameters = sourceTable.parameterLookupSources.length > 0;
|
|
239
|
-
sourceTable.storeCurrentData = sendsCompleteRows !== true;
|
|
240
|
-
|
|
241
|
-
let truncatedTables: SourceTableDecoded[] = [];
|
|
242
|
-
if (objectId != null) {
|
|
243
|
-
truncatedTables = await db.sql`
|
|
244
|
-
SELECT
|
|
245
|
-
*
|
|
246
|
-
FROM
|
|
247
|
-
source_tables
|
|
248
|
-
WHERE
|
|
249
|
-
group_id = ${{ type: 'int4', value: this.group_id }}
|
|
250
|
-
AND connection_id = ${{ type: 'int4', value: connection_id }}
|
|
251
|
-
AND id != ${{ type: 'varchar', value: sourceTableRow!.id }}
|
|
252
|
-
AND (
|
|
253
|
-
relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
|
|
254
|
-
OR (
|
|
255
|
-
schema_name = ${{ type: 'varchar', value: schema }}
|
|
256
|
-
AND table_name = ${{ type: 'varchar', value: table }}
|
|
257
|
-
)
|
|
258
|
-
)
|
|
259
|
-
`
|
|
260
|
-
.decoded(models.SourceTable)
|
|
261
|
-
.rows();
|
|
262
|
-
} else {
|
|
263
|
-
truncatedTables = await db.sql`
|
|
264
|
-
SELECT
|
|
265
|
-
*
|
|
266
|
-
FROM
|
|
267
|
-
source_tables
|
|
268
|
-
WHERE
|
|
269
|
-
group_id = ${{ type: 'int4', value: this.group_id }}
|
|
270
|
-
AND connection_id = ${{ type: 'int4', value: connection_id }}
|
|
271
|
-
AND id != ${{ type: 'varchar', value: sourceTableRow!.id }}
|
|
272
|
-
AND (
|
|
273
|
-
schema_name = ${{ type: 'varchar', value: schema }}
|
|
274
|
-
AND table_name = ${{ type: 'varchar', value: table }}
|
|
275
|
-
)
|
|
276
|
-
`
|
|
277
|
-
.decoded(models.SourceTable)
|
|
278
|
-
.rows();
|
|
279
|
-
}
|
|
257
|
+
const dropTables = [
|
|
258
|
+
...materializedResolution.incompatibleTables,
|
|
259
|
+
// PostgreSQL storage only keeps one SourceTable per physical table.
|
|
260
|
+
...compatibleTables.filter((candidate) => !storage.sourceTableIdEquals(candidate.id, sourceTable.id))
|
|
261
|
+
];
|
|
280
262
|
|
|
281
263
|
return {
|
|
282
264
|
tables: [sourceTable],
|
|
283
|
-
dropTables
|
|
284
|
-
const ref = { connectionTag, schema: doc.schema_name, name: doc.table_name };
|
|
285
|
-
const dropTable = new storage.SourceTable({
|
|
286
|
-
id: doc.id,
|
|
287
|
-
ref,
|
|
288
|
-
objectId: doc.relation_id?.object_id ?? 0,
|
|
289
|
-
replicaIdColumns:
|
|
290
|
-
doc.replica_id_columns?.map(
|
|
291
|
-
(c) => ({ name: c.name, typeId: c.typeId, type: c.type }) satisfies ColumnDescriptor
|
|
292
|
-
) ?? [],
|
|
293
|
-
snapshotComplete: doc.snapshot_done ?? true,
|
|
294
|
-
...syncRules.getMatchingSources(ref)
|
|
295
|
-
});
|
|
296
|
-
dropTable.syncEvent = syncRules.tableTriggersEvent(ref);
|
|
297
|
-
dropTable.syncData = dropTable.bucketDataSources.length > 0;
|
|
298
|
-
dropTable.syncParameters = dropTable.parameterLookupSources.length > 0;
|
|
299
|
-
return dropTable;
|
|
300
|
-
})
|
|
265
|
+
dropTables
|
|
301
266
|
};
|
|
302
267
|
});
|
|
303
268
|
}
|
|
@@ -334,9 +299,10 @@ export class PostgresBucketBatch
|
|
|
334
299
|
objectId: row.relation_id?.object_id,
|
|
335
300
|
replicaIdColumns:
|
|
336
301
|
row.replica_id_columns?.map(
|
|
337
|
-
(
|
|
302
|
+
(column) => ({ name: column.name, typeId: column.type_oid, type: column.type }) satisfies ColumnDescriptor
|
|
338
303
|
) ?? [],
|
|
339
304
|
snapshotComplete: row.snapshot_done ?? true,
|
|
305
|
+
sourceMetadata: row.source_metadata,
|
|
340
306
|
...syncRules.getMatchingSources(ref)
|
|
341
307
|
});
|
|
342
308
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { JsonValue } from '@powersync/service-core';
|
|
1
2
|
import * as t from 'ts-codec';
|
|
2
3
|
import { bigint, hexBuffer, jsonb, jsonb_raw, pgwire_number } from '../codecs.js';
|
|
3
4
|
|
|
@@ -14,7 +15,7 @@ export const ColumnDescriptor = t.object({
|
|
|
14
15
|
/**
|
|
15
16
|
* Some data sources have a type id that can be used to identify the type of the column
|
|
16
17
|
*/
|
|
17
|
-
|
|
18
|
+
type_oid: t.number.optional()
|
|
18
19
|
});
|
|
19
20
|
|
|
20
21
|
export const SourceTable = t.object({
|
|
@@ -28,7 +29,11 @@ export const SourceTable = t.object({
|
|
|
28
29
|
snapshot_done: t.boolean,
|
|
29
30
|
snapshot_total_estimated_count: t.Null.or(bigint),
|
|
30
31
|
snapshot_replicated_count: t.Null.or(bigint),
|
|
31
|
-
snapshot_last_key: t.Null.or(hexBuffer)
|
|
32
|
+
snapshot_last_key: t.Null.or(hexBuffer),
|
|
33
|
+
/**
|
|
34
|
+
* Source-specific metadata. Null for legacy records.
|
|
35
|
+
*/
|
|
36
|
+
source_metadata: t.Null.or(jsonb_raw<JsonValue>())
|
|
32
37
|
});
|
|
33
38
|
|
|
34
39
|
export type SourceTable = t.Encoded<typeof SourceTable>;
|
|
@@ -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.
|