@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.
@@ -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
- let sourceTableRow: SourceTableDecoded | null;
158
+ // Find records that overlap by name or relation id.
159
+ let candidateRows: SourceTableDecoded[];
158
160
  if (objectId != null) {
159
- sourceTableRow = await db.sql`
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 relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
168
- AND schema_name = ${{ type: 'varchar', value: schema }}
169
- AND table_name = ${{ type: 'varchar', value: table }}
170
- AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
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
- .first();
178
+ .rows();
174
179
  } else {
175
- sourceTableRow = await db.sql`
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
- .first();
192
+ .rows();
189
193
  }
190
194
 
191
- if (sourceTableRow == null) {
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
- sourceTableRow = await db.sql`
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 sourceTable = new storage.SourceTable({
222
- id: sourceTableRow!.id,
223
- ref: source,
224
- objectId,
225
- replicaIdColumns,
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: truncatedTables.map((doc) => {
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
- (c) => ({ name: c.name, typeId: c.typeId, type: c.type }) satisfies ColumnDescriptor
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
- typeId: t.number.optional()
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.