@powersync/service-module-postgres-storage 0.0.0-dev-20260515144844 → 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 +25 -6
- package/dist/.tsbuildinfo +1 -1
- package/dist/@types/storage/PostgresSyncRulesStorage.d.ts +1 -2
- package/dist/@types/storage/batch/PostgresBucketBatch.d.ts +11 -2
- package/dist/storage/PostgresSyncRulesStorage.js +8 -159
- 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 +8 -8
- package/src/storage/PostgresSyncRulesStorage.ts +12 -171
- package/src/storage/batch/PostgresBucketBatch.ts +272 -6
|
@@ -22,7 +22,6 @@ import {
|
|
|
22
22
|
import { JSONBig } from '@powersync/service-jsonbig';
|
|
23
23
|
import * as sync_rules from '@powersync/service-sync-rules';
|
|
24
24
|
import * as timers from 'timers/promises';
|
|
25
|
-
import * as uuid from 'uuid';
|
|
26
25
|
import { bigint, BIGINT_MAX } from '../types/codecs.js';
|
|
27
26
|
import { models, RequiredOperationBatchLimits } from '../types/types.js';
|
|
28
27
|
import { replicaIdToSubkey } from '../utils/bson.js';
|
|
@@ -32,7 +31,6 @@ import * as framework from '@powersync/lib-services-framework';
|
|
|
32
31
|
import { StatementParam } from '@powersync/service-jpgwire';
|
|
33
32
|
import { wrapWithAbort } from 'ix/asynciterable/operators/withabort.js';
|
|
34
33
|
import * as t from 'ts-codec';
|
|
35
|
-
import { SourceTableDecoded, StoredRelationId } from '../types/models/SourceTable.js';
|
|
36
34
|
import { pick } from '../utils/ts-codec.js';
|
|
37
35
|
import { PostgresBucketBatch } from './batch/PostgresBucketBatch.js';
|
|
38
36
|
import { PostgresWriteCheckpointAPI } from './checkpoints/PostgresWriteCheckpointAPI.js';
|
|
@@ -69,7 +67,7 @@ export class PostgresSyncRulesStorage
|
|
|
69
67
|
|
|
70
68
|
// TODO we might be able to share this in an abstract class
|
|
71
69
|
private parsedSyncRulesCache:
|
|
72
|
-
| { parsed: sync_rules.
|
|
70
|
+
| { parsed: sync_rules.HydratedSyncConfig; options: storage.ParseSyncRulesOptions }
|
|
73
71
|
| undefined;
|
|
74
72
|
private _checksumCache: storage.ChecksumCache | undefined;
|
|
75
73
|
|
|
@@ -109,14 +107,14 @@ export class PostgresSyncRulesStorage
|
|
|
109
107
|
}
|
|
110
108
|
|
|
111
109
|
// TODO we might be able to share this in an abstract class
|
|
112
|
-
getParsedSyncRules(options: storage.ParseSyncRulesOptions): sync_rules.
|
|
110
|
+
getParsedSyncRules(options: storage.ParseSyncRulesOptions): sync_rules.HydratedSyncConfig {
|
|
113
111
|
const { parsed, options: cachedOptions } = this.parsedSyncRulesCache ?? {};
|
|
114
112
|
/**
|
|
115
113
|
* Check if the cached sync config, if present, had the same options.
|
|
116
114
|
* Parse sync config if the options are different or if there is no cached value.
|
|
117
115
|
*/
|
|
118
116
|
if (!parsed || options.defaultSchema != cachedOptions?.defaultSchema) {
|
|
119
|
-
this.parsedSyncRulesCache = { parsed: this.sync_rules.parsed(options).
|
|
117
|
+
this.parsedSyncRulesCache = { parsed: this.sync_rules.parsed(options).hydratedSyncConfig(), options };
|
|
120
118
|
}
|
|
121
119
|
|
|
122
120
|
return this.parsedSyncRulesCache!.parsed;
|
|
@@ -188,168 +186,6 @@ export class PostgresSyncRulesStorage
|
|
|
188
186
|
);
|
|
189
187
|
}
|
|
190
188
|
|
|
191
|
-
async resolveTable(options: storage.ResolveTableOptions): Promise<storage.ResolveTableResult> {
|
|
192
|
-
const { group_id, connection_id, connection_tag, entity_descriptor } = options;
|
|
193
|
-
|
|
194
|
-
const { schema, name: table, objectId, replicaIdColumns } = entity_descriptor;
|
|
195
|
-
|
|
196
|
-
const normalizedReplicaIdColumns = replicaIdColumns.map((column) => ({
|
|
197
|
-
name: column.name,
|
|
198
|
-
type: column.type,
|
|
199
|
-
// The PGWire returns this as a BigInt. We want to store this as JSONB
|
|
200
|
-
type_oid: typeof column.typeId !== 'undefined' ? Number(column.typeId) : column.typeId
|
|
201
|
-
}));
|
|
202
|
-
return this.db.transaction(async (db) => {
|
|
203
|
-
let sourceTableRow: SourceTableDecoded | null;
|
|
204
|
-
if (objectId != null) {
|
|
205
|
-
sourceTableRow = await db.sql`
|
|
206
|
-
SELECT
|
|
207
|
-
*
|
|
208
|
-
FROM
|
|
209
|
-
source_tables
|
|
210
|
-
WHERE
|
|
211
|
-
group_id = ${{ type: 'int4', value: group_id }}
|
|
212
|
-
AND connection_id = ${{ type: 'int4', value: connection_id }}
|
|
213
|
-
AND relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
|
|
214
|
-
AND schema_name = ${{ type: 'varchar', value: schema }}
|
|
215
|
-
AND table_name = ${{ type: 'varchar', value: table }}
|
|
216
|
-
AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
|
|
217
|
-
`
|
|
218
|
-
.decoded(models.SourceTable)
|
|
219
|
-
.first();
|
|
220
|
-
} else {
|
|
221
|
-
sourceTableRow = await db.sql`
|
|
222
|
-
SELECT
|
|
223
|
-
*
|
|
224
|
-
FROM
|
|
225
|
-
source_tables
|
|
226
|
-
WHERE
|
|
227
|
-
group_id = ${{ type: 'int4', value: group_id }}
|
|
228
|
-
AND connection_id = ${{ type: 'int4', value: connection_id }}
|
|
229
|
-
AND schema_name = ${{ type: 'varchar', value: schema }}
|
|
230
|
-
AND table_name = ${{ type: 'varchar', value: table }}
|
|
231
|
-
AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
|
|
232
|
-
`
|
|
233
|
-
.decoded(models.SourceTable)
|
|
234
|
-
.first();
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
if (sourceTableRow == null) {
|
|
238
|
-
const row = await db.sql`
|
|
239
|
-
INSERT INTO
|
|
240
|
-
source_tables (
|
|
241
|
-
id,
|
|
242
|
-
group_id,
|
|
243
|
-
connection_id,
|
|
244
|
-
relation_id,
|
|
245
|
-
schema_name,
|
|
246
|
-
table_name,
|
|
247
|
-
replica_id_columns
|
|
248
|
-
)
|
|
249
|
-
VALUES
|
|
250
|
-
(
|
|
251
|
-
${{ type: 'varchar', value: uuid.v4() }},
|
|
252
|
-
${{ type: 'int4', value: group_id }},
|
|
253
|
-
${{ type: 'int4', value: connection_id }},
|
|
254
|
-
--- The objectId can be string | number | undefined, we store it as jsonb value
|
|
255
|
-
${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }},
|
|
256
|
-
${{ type: 'varchar', value: schema }},
|
|
257
|
-
${{ type: 'varchar', value: table }},
|
|
258
|
-
${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
|
|
259
|
-
)
|
|
260
|
-
RETURNING
|
|
261
|
-
*
|
|
262
|
-
`
|
|
263
|
-
.decoded(models.SourceTable)
|
|
264
|
-
.first();
|
|
265
|
-
sourceTableRow = row;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const sourceTable = new storage.SourceTable({
|
|
269
|
-
id: sourceTableRow!.id,
|
|
270
|
-
connectionTag: connection_tag,
|
|
271
|
-
objectId: objectId,
|
|
272
|
-
schema: schema,
|
|
273
|
-
name: table,
|
|
274
|
-
replicaIdColumns: replicaIdColumns,
|
|
275
|
-
snapshotComplete: sourceTableRow!.snapshot_done ?? true
|
|
276
|
-
});
|
|
277
|
-
if (!sourceTable.snapshotComplete) {
|
|
278
|
-
sourceTable.snapshotStatus = {
|
|
279
|
-
totalEstimatedCount: Number(sourceTableRow!.snapshot_total_estimated_count ?? -1n),
|
|
280
|
-
replicatedCount: Number(sourceTableRow!.snapshot_replicated_count ?? 0n),
|
|
281
|
-
lastKey: sourceTableRow!.snapshot_last_key
|
|
282
|
-
};
|
|
283
|
-
}
|
|
284
|
-
sourceTable.syncEvent = options.sync_rules.tableTriggersEvent(sourceTable);
|
|
285
|
-
sourceTable.syncData = options.sync_rules.tableSyncsData(sourceTable);
|
|
286
|
-
sourceTable.syncParameters = options.sync_rules.tableSyncsParameters(sourceTable);
|
|
287
|
-
|
|
288
|
-
let truncatedTables: SourceTableDecoded[] = [];
|
|
289
|
-
if (objectId != null) {
|
|
290
|
-
// relation_id present - check for renamed tables
|
|
291
|
-
truncatedTables = await db.sql`
|
|
292
|
-
SELECT
|
|
293
|
-
*
|
|
294
|
-
FROM
|
|
295
|
-
source_tables
|
|
296
|
-
WHERE
|
|
297
|
-
group_id = ${{ type: 'int4', value: group_id }}
|
|
298
|
-
AND connection_id = ${{ type: 'int4', value: connection_id }}
|
|
299
|
-
AND id != ${{ type: 'varchar', value: sourceTableRow!.id }}
|
|
300
|
-
AND (
|
|
301
|
-
relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
|
|
302
|
-
OR (
|
|
303
|
-
schema_name = ${{ type: 'varchar', value: schema }}
|
|
304
|
-
AND table_name = ${{ type: 'varchar', value: table }}
|
|
305
|
-
)
|
|
306
|
-
)
|
|
307
|
-
`
|
|
308
|
-
.decoded(models.SourceTable)
|
|
309
|
-
.rows();
|
|
310
|
-
} else {
|
|
311
|
-
// relation_id not present - only check for changed replica_id_columns
|
|
312
|
-
truncatedTables = await db.sql`
|
|
313
|
-
SELECT
|
|
314
|
-
*
|
|
315
|
-
FROM
|
|
316
|
-
source_tables
|
|
317
|
-
WHERE
|
|
318
|
-
group_id = ${{ type: 'int4', value: group_id }}
|
|
319
|
-
AND connection_id = ${{ type: 'int4', value: connection_id }}
|
|
320
|
-
AND id != ${{ type: 'varchar', value: sourceTableRow!.id }}
|
|
321
|
-
AND (
|
|
322
|
-
schema_name = ${{ type: 'varchar', value: schema }}
|
|
323
|
-
AND table_name = ${{ type: 'varchar', value: table }}
|
|
324
|
-
)
|
|
325
|
-
`
|
|
326
|
-
.decoded(models.SourceTable)
|
|
327
|
-
.rows();
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
return {
|
|
331
|
-
table: sourceTable,
|
|
332
|
-
dropTables: truncatedTables.map(
|
|
333
|
-
(doc) =>
|
|
334
|
-
new storage.SourceTable({
|
|
335
|
-
id: doc.id,
|
|
336
|
-
connectionTag: connection_tag,
|
|
337
|
-
objectId: doc.relation_id?.object_id ?? 0,
|
|
338
|
-
schema: doc.schema_name,
|
|
339
|
-
name: doc.table_name,
|
|
340
|
-
replicaIdColumns:
|
|
341
|
-
doc.replica_id_columns?.map((c) => ({
|
|
342
|
-
name: c.name,
|
|
343
|
-
typeOid: c.typeId,
|
|
344
|
-
type: c.type
|
|
345
|
-
})) ?? [],
|
|
346
|
-
snapshotComplete: doc.snapshot_done ?? true
|
|
347
|
-
})
|
|
348
|
-
)
|
|
349
|
-
};
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
|
|
353
189
|
async createWriter(options: storage.CreateWriterOptions): Promise<storage.BucketStorageBatch> {
|
|
354
190
|
const syncRules = await this.db.sql`
|
|
355
191
|
SELECT
|
|
@@ -370,7 +206,7 @@ export class PostgresSyncRulesStorage
|
|
|
370
206
|
const writer = new PostgresBucketBatch({
|
|
371
207
|
logger: options.logger ?? this.logger,
|
|
372
208
|
db: this.db,
|
|
373
|
-
sync_rules: this.sync_rules.parsed(options).
|
|
209
|
+
sync_rules: this.sync_rules.parsed(options).hydratedSyncConfig(),
|
|
374
210
|
group_id: this.group_id,
|
|
375
211
|
slot_name: this.slot_name,
|
|
376
212
|
last_checkpoint_lsn: checkpoint_lsn,
|
|
@@ -380,6 +216,7 @@ export class PostgresSyncRulesStorage
|
|
|
380
216
|
skip_existing_rows: options.skipExistingRows ?? false,
|
|
381
217
|
batch_limits: this.options.batchLimits,
|
|
382
218
|
markRecordUnavailable: options.markRecordUnavailable,
|
|
219
|
+
hooks: options.hooks,
|
|
383
220
|
storageConfig: this.storageConfig
|
|
384
221
|
});
|
|
385
222
|
this.iterateListeners((cb) => cb.batchStarted?.(writer));
|
|
@@ -670,13 +507,16 @@ export class PostgresSyncRulesStorage
|
|
|
670
507
|
snapshot_done,
|
|
671
508
|
snapshot_lsn,
|
|
672
509
|
last_checkpoint_lsn,
|
|
673
|
-
state
|
|
510
|
+
state,
|
|
511
|
+
keepalive_op
|
|
674
512
|
FROM
|
|
675
513
|
sync_rules
|
|
676
514
|
WHERE
|
|
677
515
|
id = ${{ type: 'int4', value: this.group_id }}
|
|
678
516
|
`
|
|
679
|
-
.decoded(
|
|
517
|
+
.decoded(
|
|
518
|
+
pick(models.SyncRules, ['snapshot_done', 'last_checkpoint_lsn', 'state', 'snapshot_lsn', 'keepalive_op'])
|
|
519
|
+
)
|
|
680
520
|
.first();
|
|
681
521
|
|
|
682
522
|
if (syncRulesRow == null) {
|
|
@@ -687,7 +527,8 @@ export class PostgresSyncRulesStorage
|
|
|
687
527
|
snapshot_done: syncRulesRow.snapshot_done,
|
|
688
528
|
active: syncRulesRow.state == storage.SyncRuleState.ACTIVE,
|
|
689
529
|
checkpoint_lsn: syncRulesRow.last_checkpoint_lsn ?? null,
|
|
690
|
-
snapshot_lsn: syncRulesRow.snapshot_lsn ?? null
|
|
530
|
+
snapshot_lsn: syncRulesRow.snapshot_lsn ?? null,
|
|
531
|
+
keepalive_op: syncRulesRow.keepalive_op ?? null
|
|
691
532
|
};
|
|
692
533
|
}
|
|
693
534
|
|
|
@@ -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
|
|