@powersync/service-module-postgres-storage 0.0.0-dev-20260511080634 → 0.0.0-dev-20260601140105
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 +43 -8
- package/dist/.tsbuildinfo +1 -1
- package/dist/@types/storage/PostgresSyncRulesStorage.d.ts +2 -3
- package/dist/@types/storage/batch/PostgresBucketBatch.d.ts +11 -2
- package/dist/storage/PostgresSyncRulesStorage.js +41 -182
- package/dist/storage/PostgresSyncRulesStorage.js.map +1 -1
- package/dist/storage/batch/PostgresBucketBatch.js +240 -3
- package/dist/storage/batch/PostgresBucketBatch.js.map +1 -1
- package/package.json +9 -9
- package/src/storage/PostgresSyncRulesStorage.ts +52 -196
- package/src/storage/batch/PostgresBucketBatch.ts +272 -6
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
BucketStorageMarkRecordUnavailable,
|
|
14
14
|
CheckpointResult,
|
|
15
|
+
ColumnDescriptor,
|
|
15
16
|
deserializeReplicaId,
|
|
16
17
|
InternalOpId,
|
|
17
18
|
storage,
|
|
@@ -20,8 +21,10 @@ import {
|
|
|
20
21
|
import * as sync_rules from '@powersync/service-sync-rules';
|
|
21
22
|
import * as timers from 'timers/promises';
|
|
22
23
|
import * as t from 'ts-codec';
|
|
24
|
+
import * as uuid from 'uuid';
|
|
23
25
|
import { bigint } from '../../types/codecs.js';
|
|
24
26
|
import { CurrentBucket, V3CurrentDataDecoded } from '../../types/models/CurrentData.js';
|
|
27
|
+
import { SourceTableDecoded, StoredRelationId } from '../../types/models/SourceTable.js';
|
|
25
28
|
import { models, RequiredOperationBatchLimits } from '../../types/types.js';
|
|
26
29
|
import { NOTIFICATION_CHANNEL } from '../../utils/db.js';
|
|
27
30
|
import { pick } from '../../utils/ts-codec.js';
|
|
@@ -34,7 +37,7 @@ import { PostgresPersistedBatch } from './PostgresPersistedBatch.js';
|
|
|
34
37
|
export interface PostgresBucketBatchOptions {
|
|
35
38
|
logger: Logger;
|
|
36
39
|
db: lib_postgres.DatabaseClient;
|
|
37
|
-
sync_rules: sync_rules.
|
|
40
|
+
sync_rules: sync_rules.HydratedSyncConfig;
|
|
38
41
|
group_id: number;
|
|
39
42
|
slot_name: string;
|
|
40
43
|
last_checkpoint_lsn: string | null;
|
|
@@ -48,6 +51,7 @@ export interface PostgresBucketBatchOptions {
|
|
|
48
51
|
batch_limits: RequiredOperationBatchLimits;
|
|
49
52
|
|
|
50
53
|
markRecordUnavailable: BucketStorageMarkRecordUnavailable | undefined;
|
|
54
|
+
hooks: storage.StorageHooks | undefined;
|
|
51
55
|
storageConfig: storage.StorageVersionConfig;
|
|
52
56
|
}
|
|
53
57
|
|
|
@@ -85,6 +89,7 @@ export class PostgresBucketBatch
|
|
|
85
89
|
public last_flushed_op: InternalOpId | null = null;
|
|
86
90
|
|
|
87
91
|
public resumeFromLsn: string | null;
|
|
92
|
+
public readonly skipExistingRows: boolean;
|
|
88
93
|
|
|
89
94
|
protected db: lib_postgres.DatabaseClient;
|
|
90
95
|
protected group_id: number;
|
|
@@ -93,10 +98,11 @@ export class PostgresBucketBatch
|
|
|
93
98
|
protected persisted_op: InternalOpId | null;
|
|
94
99
|
|
|
95
100
|
protected write_checkpoint_batch: storage.CustomWriteCheckpointOptions[];
|
|
96
|
-
protected readonly sync_rules: sync_rules.
|
|
101
|
+
protected readonly sync_rules: sync_rules.HydratedSyncConfig;
|
|
97
102
|
protected batch: OperationBatch | null;
|
|
98
103
|
private lastWaitingLogThrottled = 0;
|
|
99
104
|
private markRecordUnavailable: BucketStorageMarkRecordUnavailable | undefined;
|
|
105
|
+
private hooks: storage.StorageHooks | undefined;
|
|
100
106
|
private needsActivation = true;
|
|
101
107
|
private clearedError = false;
|
|
102
108
|
private readonly storageConfig: storage.StorageVersionConfig;
|
|
@@ -109,9 +115,11 @@ export class PostgresBucketBatch
|
|
|
109
115
|
this.group_id = options.group_id;
|
|
110
116
|
this.last_checkpoint_lsn = options.last_checkpoint_lsn;
|
|
111
117
|
this.resumeFromLsn = options.resumeFromLsn;
|
|
118
|
+
this.skipExistingRows = options.skip_existing_rows;
|
|
112
119
|
this.write_checkpoint_batch = [];
|
|
113
120
|
this.sync_rules = options.sync_rules;
|
|
114
121
|
this.markRecordUnavailable = options.markRecordUnavailable;
|
|
122
|
+
this.hooks = options.hooks;
|
|
115
123
|
this.batch = null;
|
|
116
124
|
this.persisted_op = null;
|
|
117
125
|
this.storageConfig = options.storageConfig;
|
|
@@ -139,6 +147,215 @@ export class PostgresBucketBatch
|
|
|
139
147
|
await this[Symbol.asyncDispose]();
|
|
140
148
|
}
|
|
141
149
|
|
|
150
|
+
async resolveTables(options: storage.ResolveTablesOptions): Promise<storage.ResolveTablesResult> {
|
|
151
|
+
const syncRules = options.syncRules ?? this.sync_rules;
|
|
152
|
+
const { connection_id, source } = options;
|
|
153
|
+
const { schema, name: table, objectId, replicaIdColumns, connectionTag } = source;
|
|
154
|
+
|
|
155
|
+
const normalizedReplicaIdColumns = replicaIdColumns.map((column) => ({
|
|
156
|
+
name: column.name,
|
|
157
|
+
type: column.type,
|
|
158
|
+
type_oid: typeof column.typeId !== 'undefined' ? Number(column.typeId) : column.typeId
|
|
159
|
+
}));
|
|
160
|
+
return this.db.transaction(async (db) => {
|
|
161
|
+
let sourceTableRow: SourceTableDecoded | null;
|
|
162
|
+
if (objectId != null) {
|
|
163
|
+
sourceTableRow = await db.sql`
|
|
164
|
+
SELECT
|
|
165
|
+
*
|
|
166
|
+
FROM
|
|
167
|
+
source_tables
|
|
168
|
+
WHERE
|
|
169
|
+
group_id = ${{ type: 'int4', value: this.group_id }}
|
|
170
|
+
AND connection_id = ${{ type: 'int4', value: connection_id }}
|
|
171
|
+
AND relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
|
|
172
|
+
AND schema_name = ${{ type: 'varchar', value: schema }}
|
|
173
|
+
AND table_name = ${{ type: 'varchar', value: table }}
|
|
174
|
+
AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
|
|
175
|
+
`
|
|
176
|
+
.decoded(models.SourceTable)
|
|
177
|
+
.first();
|
|
178
|
+
} else {
|
|
179
|
+
sourceTableRow = await db.sql`
|
|
180
|
+
SELECT
|
|
181
|
+
*
|
|
182
|
+
FROM
|
|
183
|
+
source_tables
|
|
184
|
+
WHERE
|
|
185
|
+
group_id = ${{ type: 'int4', value: this.group_id }}
|
|
186
|
+
AND connection_id = ${{ type: 'int4', value: connection_id }}
|
|
187
|
+
AND schema_name = ${{ type: 'varchar', value: schema }}
|
|
188
|
+
AND table_name = ${{ type: 'varchar', value: table }}
|
|
189
|
+
AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
|
|
190
|
+
`
|
|
191
|
+
.decoded(models.SourceTable)
|
|
192
|
+
.first();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (sourceTableRow == null) {
|
|
196
|
+
const id = options.idGenerator ? postgresTableId(options.idGenerator()) : uuid.v4();
|
|
197
|
+
sourceTableRow = await db.sql`
|
|
198
|
+
INSERT INTO
|
|
199
|
+
source_tables (
|
|
200
|
+
id,
|
|
201
|
+
group_id,
|
|
202
|
+
connection_id,
|
|
203
|
+
relation_id,
|
|
204
|
+
schema_name,
|
|
205
|
+
table_name,
|
|
206
|
+
replica_id_columns
|
|
207
|
+
)
|
|
208
|
+
VALUES
|
|
209
|
+
(
|
|
210
|
+
${{ type: 'varchar', value: id }},
|
|
211
|
+
${{ type: 'int4', value: this.group_id }},
|
|
212
|
+
${{ type: 'int4', value: connection_id }},
|
|
213
|
+
${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }},
|
|
214
|
+
${{ type: 'varchar', value: schema }},
|
|
215
|
+
${{ type: 'varchar', value: table }},
|
|
216
|
+
${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
|
|
217
|
+
)
|
|
218
|
+
RETURNING
|
|
219
|
+
*
|
|
220
|
+
`
|
|
221
|
+
.decoded(models.SourceTable)
|
|
222
|
+
.first();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const sourceTable = new storage.SourceTable({
|
|
226
|
+
id: sourceTableRow!.id,
|
|
227
|
+
ref: source,
|
|
228
|
+
objectId,
|
|
229
|
+
replicaIdColumns,
|
|
230
|
+
snapshotComplete: sourceTableRow!.snapshot_done ?? true,
|
|
231
|
+
...syncRules.getMatchingSources(source)
|
|
232
|
+
});
|
|
233
|
+
if (!sourceTable.snapshotComplete) {
|
|
234
|
+
sourceTable.snapshotStatus = {
|
|
235
|
+
totalEstimatedCount: Number(sourceTableRow!.snapshot_total_estimated_count ?? -1n),
|
|
236
|
+
replicatedCount: Number(sourceTableRow!.snapshot_replicated_count ?? 0n),
|
|
237
|
+
lastKey: sourceTableRow!.snapshot_last_key
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
sourceTable.syncEvent = syncRules.tableTriggersEvent(source);
|
|
241
|
+
sourceTable.syncData = sourceTable.bucketDataSources.length > 0;
|
|
242
|
+
sourceTable.syncParameters = sourceTable.parameterLookupSources.length > 0;
|
|
243
|
+
|
|
244
|
+
let truncatedTables: SourceTableDecoded[] = [];
|
|
245
|
+
if (objectId != null) {
|
|
246
|
+
truncatedTables = await db.sql`
|
|
247
|
+
SELECT
|
|
248
|
+
*
|
|
249
|
+
FROM
|
|
250
|
+
source_tables
|
|
251
|
+
WHERE
|
|
252
|
+
group_id = ${{ type: 'int4', value: this.group_id }}
|
|
253
|
+
AND connection_id = ${{ type: 'int4', value: connection_id }}
|
|
254
|
+
AND id != ${{ type: 'varchar', value: sourceTableRow!.id }}
|
|
255
|
+
AND (
|
|
256
|
+
relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
|
|
257
|
+
OR (
|
|
258
|
+
schema_name = ${{ type: 'varchar', value: schema }}
|
|
259
|
+
AND table_name = ${{ type: 'varchar', value: table }}
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
`
|
|
263
|
+
.decoded(models.SourceTable)
|
|
264
|
+
.rows();
|
|
265
|
+
} else {
|
|
266
|
+
truncatedTables = await db.sql`
|
|
267
|
+
SELECT
|
|
268
|
+
*
|
|
269
|
+
FROM
|
|
270
|
+
source_tables
|
|
271
|
+
WHERE
|
|
272
|
+
group_id = ${{ type: 'int4', value: this.group_id }}
|
|
273
|
+
AND connection_id = ${{ type: 'int4', value: connection_id }}
|
|
274
|
+
AND id != ${{ type: 'varchar', value: sourceTableRow!.id }}
|
|
275
|
+
AND (
|
|
276
|
+
schema_name = ${{ type: 'varchar', value: schema }}
|
|
277
|
+
AND table_name = ${{ type: 'varchar', value: table }}
|
|
278
|
+
)
|
|
279
|
+
`
|
|
280
|
+
.decoded(models.SourceTable)
|
|
281
|
+
.rows();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
tables: [sourceTable],
|
|
286
|
+
dropTables: truncatedTables.map((doc) => {
|
|
287
|
+
const ref = { connectionTag, schema: doc.schema_name, name: doc.table_name };
|
|
288
|
+
const dropTable = new storage.SourceTable({
|
|
289
|
+
id: doc.id,
|
|
290
|
+
ref,
|
|
291
|
+
objectId: doc.relation_id?.object_id ?? 0,
|
|
292
|
+
replicaIdColumns:
|
|
293
|
+
doc.replica_id_columns?.map(
|
|
294
|
+
(c) => ({ name: c.name, typeId: c.typeId, type: c.type }) satisfies ColumnDescriptor
|
|
295
|
+
) ?? [],
|
|
296
|
+
snapshotComplete: doc.snapshot_done ?? true,
|
|
297
|
+
...syncRules.getMatchingSources(ref)
|
|
298
|
+
});
|
|
299
|
+
dropTable.syncEvent = syncRules.tableTriggersEvent(ref);
|
|
300
|
+
dropTable.syncData = dropTable.bucketDataSources.length > 0;
|
|
301
|
+
dropTable.syncParameters = dropTable.parameterLookupSources.length > 0;
|
|
302
|
+
return dropTable;
|
|
303
|
+
})
|
|
304
|
+
};
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async getSourceTableStatus(table: storage.SourceTable): Promise<storage.SourceTable | null> {
|
|
309
|
+
const row = await this.db.sql`
|
|
310
|
+
SELECT
|
|
311
|
+
*
|
|
312
|
+
FROM
|
|
313
|
+
source_tables
|
|
314
|
+
WHERE
|
|
315
|
+
group_id = ${{ type: 'int4', value: this.group_id }}
|
|
316
|
+
AND id = ${{ type: 'varchar', value: table.id.toString() }}
|
|
317
|
+
`
|
|
318
|
+
.decoded(models.SourceTable)
|
|
319
|
+
.first();
|
|
320
|
+
|
|
321
|
+
if (row == null) {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return this.sourceTableFromRow(row, table.ref.connectionTag, this.sync_rules);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private sourceTableFromRow(
|
|
329
|
+
row: SourceTableDecoded,
|
|
330
|
+
connectionTag: string,
|
|
331
|
+
syncRules: sync_rules.HydratedSyncConfig
|
|
332
|
+
): storage.SourceTable {
|
|
333
|
+
const ref = { connectionTag, schema: row.schema_name, name: row.table_name };
|
|
334
|
+
const sourceTable = new storage.SourceTable({
|
|
335
|
+
id: row.id,
|
|
336
|
+
ref,
|
|
337
|
+
objectId: row.relation_id?.object_id,
|
|
338
|
+
replicaIdColumns:
|
|
339
|
+
row.replica_id_columns?.map(
|
|
340
|
+
(c) => ({ name: c.name, typeId: c.typeId, type: c.type }) satisfies ColumnDescriptor
|
|
341
|
+
) ?? [],
|
|
342
|
+
snapshotComplete: row.snapshot_done ?? true,
|
|
343
|
+
...syncRules.getMatchingSources(ref)
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
if (!sourceTable.snapshotComplete) {
|
|
347
|
+
sourceTable.snapshotStatus = {
|
|
348
|
+
totalEstimatedCount: Number(row.snapshot_total_estimated_count ?? -1n),
|
|
349
|
+
replicatedCount: Number(row.snapshot_replicated_count ?? 0n),
|
|
350
|
+
lastKey: row.snapshot_last_key
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
sourceTable.syncEvent = syncRules.tableTriggersEvent(ref);
|
|
354
|
+
sourceTable.syncData = sourceTable.bucketDataSources.length > 0;
|
|
355
|
+
sourceTable.syncParameters = sourceTable.parameterLookupSources.length > 0;
|
|
356
|
+
return sourceTable;
|
|
357
|
+
}
|
|
358
|
+
|
|
142
359
|
async save(record: storage.SaveOptions): Promise<storage.FlushedResult | null> {
|
|
143
360
|
// TODO maybe share with abstract class
|
|
144
361
|
const { after, before, sourceTable, tag } = record;
|
|
@@ -296,6 +513,8 @@ export class PostgresBucketBatch
|
|
|
296
513
|
return null;
|
|
297
514
|
}
|
|
298
515
|
|
|
516
|
+
await this.hooks?.beforeBatchFlush?.(this);
|
|
517
|
+
|
|
299
518
|
let resumeBatch: OperationBatch | null = null;
|
|
300
519
|
|
|
301
520
|
const lastOp = await this.withReplicationTransaction(async (db) => {
|
|
@@ -313,6 +532,7 @@ export class PostgresBucketBatch
|
|
|
313
532
|
|
|
314
533
|
this.persisted_op = lastOp;
|
|
315
534
|
this.last_flushed_op = lastOp;
|
|
535
|
+
await this.hooks?.afterBatchFlush?.(this);
|
|
316
536
|
return { flushed_op: lastOp };
|
|
317
537
|
}
|
|
318
538
|
|
|
@@ -536,6 +756,50 @@ export class PostgresBucketBatch
|
|
|
536
756
|
});
|
|
537
757
|
}
|
|
538
758
|
|
|
759
|
+
async markSnapshotDone(no_checkpoint_before_lsn: string, options?: { throwOnConflict?: boolean }): Promise<void> {
|
|
760
|
+
await this.db.transaction(async (db) => {
|
|
761
|
+
const snapshotRequiredCount = await db.sql`
|
|
762
|
+
SELECT
|
|
763
|
+
COUNT(*) AS count
|
|
764
|
+
FROM
|
|
765
|
+
source_tables
|
|
766
|
+
WHERE
|
|
767
|
+
group_id = ${{ type: 'int4', value: this.group_id }}
|
|
768
|
+
AND snapshot_done = FALSE
|
|
769
|
+
`
|
|
770
|
+
.decoded(t.object({ count: bigint }))
|
|
771
|
+
.first();
|
|
772
|
+
if ((snapshotRequiredCount?.count ?? 0n) > 0n) {
|
|
773
|
+
if (options?.throwOnConflict ?? true) {
|
|
774
|
+
throw new ReplicationAssertionError(
|
|
775
|
+
`Cannot mark snapshot done while ${snapshotRequiredCount?.count} source table${
|
|
776
|
+
snapshotRequiredCount?.count == 1n ? '' : 's'
|
|
777
|
+
} still require snapshotting`
|
|
778
|
+
);
|
|
779
|
+
} else {
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
await db.sql`
|
|
785
|
+
UPDATE sync_rules
|
|
786
|
+
SET
|
|
787
|
+
snapshot_done = TRUE,
|
|
788
|
+
last_keepalive_ts = ${{ type: 1184, value: new Date().toISOString() }},
|
|
789
|
+
no_checkpoint_before = CASE
|
|
790
|
+
WHEN no_checkpoint_before IS NULL
|
|
791
|
+
OR no_checkpoint_before < ${{ type: 'varchar', value: no_checkpoint_before_lsn }} THEN ${{
|
|
792
|
+
type: 'varchar',
|
|
793
|
+
value: no_checkpoint_before_lsn
|
|
794
|
+
}}
|
|
795
|
+
ELSE no_checkpoint_before
|
|
796
|
+
END
|
|
797
|
+
WHERE
|
|
798
|
+
id = ${{ type: 'int4', value: this.group_id }}
|
|
799
|
+
`.execute();
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
539
803
|
async markTableSnapshotRequired(table: storage.SourceTable): Promise<void> {
|
|
540
804
|
await this.db.sql`
|
|
541
805
|
UPDATE sync_rules
|
|
@@ -900,7 +1164,8 @@ export class PostgresBucketBatch
|
|
|
900
1164
|
if (sourceTable.syncData) {
|
|
901
1165
|
const { results: evaluated, errors: syncErrors } = this.sync_rules.evaluateRowWithErrors({
|
|
902
1166
|
record: after,
|
|
903
|
-
sourceTable
|
|
1167
|
+
sourceTable: sourceTable.ref,
|
|
1168
|
+
bucketDataSources: sourceTable.bucketDataSources
|
|
904
1169
|
});
|
|
905
1170
|
|
|
906
1171
|
for (const error of syncErrors) {
|
|
@@ -939,8 +1204,9 @@ export class PostgresBucketBatch
|
|
|
939
1204
|
if (sourceTable.syncParameters) {
|
|
940
1205
|
// Parameters
|
|
941
1206
|
const { results: paramEvaluated, errors: paramErrors } = this.sync_rules.evaluateParameterRowWithErrors(
|
|
942
|
-
sourceTable,
|
|
943
|
-
after
|
|
1207
|
+
sourceTable.ref,
|
|
1208
|
+
after,
|
|
1209
|
+
{ parameterLookupSources: sourceTable.parameterLookupSources }
|
|
944
1210
|
);
|
|
945
1211
|
|
|
946
1212
|
for (let error of paramErrors) {
|
|
@@ -1064,7 +1330,7 @@ export class PostgresBucketBatch
|
|
|
1064
1330
|
*/
|
|
1065
1331
|
protected getTableEvents(table: storage.SourceTable): sync_rules.SqlEventDescriptor[] {
|
|
1066
1332
|
return this.sync_rules.eventDescriptors.filter((evt) =>
|
|
1067
|
-
[...evt.getSourceTables()].some((sourceTable) => sourceTable.matches(table))
|
|
1333
|
+
[...evt.getSourceTables()].some((sourceTable) => sourceTable.matches(table.ref))
|
|
1068
1334
|
);
|
|
1069
1335
|
}
|
|
1070
1336
|
|