@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
|
@@ -22,8 +22,7 @@ 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
|
|
26
|
-
import { BIGINT_MAX } from '../types/codecs.js';
|
|
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';
|
|
29
28
|
import { mapOpEntry } from '../utils/bucket-data.js';
|
|
@@ -31,7 +30,7 @@ import { mapOpEntry } from '../utils/bucket-data.js';
|
|
|
31
30
|
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
|
-
import
|
|
33
|
+
import * as t from 'ts-codec';
|
|
35
34
|
import { pick } from '../utils/ts-codec.js';
|
|
36
35
|
import { PostgresBucketBatch } from './batch/PostgresBucketBatch.js';
|
|
37
36
|
import { PostgresWriteCheckpointAPI } from './checkpoints/PostgresWriteCheckpointAPI.js';
|
|
@@ -68,7 +67,7 @@ export class PostgresSyncRulesStorage
|
|
|
68
67
|
|
|
69
68
|
// TODO we might be able to share this in an abstract class
|
|
70
69
|
private parsedSyncRulesCache:
|
|
71
|
-
| { parsed: sync_rules.
|
|
70
|
+
| { parsed: sync_rules.HydratedSyncConfig; options: storage.ParseSyncRulesOptions }
|
|
72
71
|
| undefined;
|
|
73
72
|
private _checksumCache: storage.ChecksumCache | undefined;
|
|
74
73
|
|
|
@@ -108,14 +107,14 @@ export class PostgresSyncRulesStorage
|
|
|
108
107
|
}
|
|
109
108
|
|
|
110
109
|
// TODO we might be able to share this in an abstract class
|
|
111
|
-
getParsedSyncRules(options: storage.ParseSyncRulesOptions): sync_rules.
|
|
110
|
+
getParsedSyncRules(options: storage.ParseSyncRulesOptions): sync_rules.HydratedSyncConfig {
|
|
112
111
|
const { parsed, options: cachedOptions } = this.parsedSyncRulesCache ?? {};
|
|
113
112
|
/**
|
|
114
113
|
* Check if the cached sync config, if present, had the same options.
|
|
115
114
|
* Parse sync config if the options are different or if there is no cached value.
|
|
116
115
|
*/
|
|
117
116
|
if (!parsed || options.defaultSchema != cachedOptions?.defaultSchema) {
|
|
118
|
-
this.parsedSyncRulesCache = { parsed: this.sync_rules.parsed(options).
|
|
117
|
+
this.parsedSyncRulesCache = { parsed: this.sync_rules.parsed(options).hydratedSyncConfig(), options };
|
|
119
118
|
}
|
|
120
119
|
|
|
121
120
|
return this.parsedSyncRulesCache!.parsed;
|
|
@@ -187,168 +186,6 @@ export class PostgresSyncRulesStorage
|
|
|
187
186
|
);
|
|
188
187
|
}
|
|
189
188
|
|
|
190
|
-
async resolveTable(options: storage.ResolveTableOptions): Promise<storage.ResolveTableResult> {
|
|
191
|
-
const { group_id, connection_id, connection_tag, entity_descriptor } = options;
|
|
192
|
-
|
|
193
|
-
const { schema, name: table, objectId, replicaIdColumns } = entity_descriptor;
|
|
194
|
-
|
|
195
|
-
const normalizedReplicaIdColumns = replicaIdColumns.map((column) => ({
|
|
196
|
-
name: column.name,
|
|
197
|
-
type: column.type,
|
|
198
|
-
// The PGWire returns this as a BigInt. We want to store this as JSONB
|
|
199
|
-
type_oid: typeof column.typeId !== 'undefined' ? Number(column.typeId) : column.typeId
|
|
200
|
-
}));
|
|
201
|
-
return this.db.transaction(async (db) => {
|
|
202
|
-
let sourceTableRow: SourceTableDecoded | null;
|
|
203
|
-
if (objectId != null) {
|
|
204
|
-
sourceTableRow = await db.sql`
|
|
205
|
-
SELECT
|
|
206
|
-
*
|
|
207
|
-
FROM
|
|
208
|
-
source_tables
|
|
209
|
-
WHERE
|
|
210
|
-
group_id = ${{ type: 'int4', value: group_id }}
|
|
211
|
-
AND connection_id = ${{ type: 'int4', value: connection_id }}
|
|
212
|
-
AND relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
|
|
213
|
-
AND schema_name = ${{ type: 'varchar', value: schema }}
|
|
214
|
-
AND table_name = ${{ type: 'varchar', value: table }}
|
|
215
|
-
AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
|
|
216
|
-
`
|
|
217
|
-
.decoded(models.SourceTable)
|
|
218
|
-
.first();
|
|
219
|
-
} else {
|
|
220
|
-
sourceTableRow = await db.sql`
|
|
221
|
-
SELECT
|
|
222
|
-
*
|
|
223
|
-
FROM
|
|
224
|
-
source_tables
|
|
225
|
-
WHERE
|
|
226
|
-
group_id = ${{ type: 'int4', value: group_id }}
|
|
227
|
-
AND connection_id = ${{ type: 'int4', value: connection_id }}
|
|
228
|
-
AND schema_name = ${{ type: 'varchar', value: schema }}
|
|
229
|
-
AND table_name = ${{ type: 'varchar', value: table }}
|
|
230
|
-
AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
|
|
231
|
-
`
|
|
232
|
-
.decoded(models.SourceTable)
|
|
233
|
-
.first();
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (sourceTableRow == null) {
|
|
237
|
-
const row = await db.sql`
|
|
238
|
-
INSERT INTO
|
|
239
|
-
source_tables (
|
|
240
|
-
id,
|
|
241
|
-
group_id,
|
|
242
|
-
connection_id,
|
|
243
|
-
relation_id,
|
|
244
|
-
schema_name,
|
|
245
|
-
table_name,
|
|
246
|
-
replica_id_columns
|
|
247
|
-
)
|
|
248
|
-
VALUES
|
|
249
|
-
(
|
|
250
|
-
${{ type: 'varchar', value: uuid.v4() }},
|
|
251
|
-
${{ type: 'int4', value: group_id }},
|
|
252
|
-
${{ type: 'int4', value: connection_id }},
|
|
253
|
-
--- The objectId can be string | number | undefined, we store it as jsonb value
|
|
254
|
-
${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }},
|
|
255
|
-
${{ type: 'varchar', value: schema }},
|
|
256
|
-
${{ type: 'varchar', value: table }},
|
|
257
|
-
${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
|
|
258
|
-
)
|
|
259
|
-
RETURNING
|
|
260
|
-
*
|
|
261
|
-
`
|
|
262
|
-
.decoded(models.SourceTable)
|
|
263
|
-
.first();
|
|
264
|
-
sourceTableRow = row;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const sourceTable = new storage.SourceTable({
|
|
268
|
-
id: sourceTableRow!.id,
|
|
269
|
-
connectionTag: connection_tag,
|
|
270
|
-
objectId: objectId,
|
|
271
|
-
schema: schema,
|
|
272
|
-
name: table,
|
|
273
|
-
replicaIdColumns: replicaIdColumns,
|
|
274
|
-
snapshotComplete: sourceTableRow!.snapshot_done ?? true
|
|
275
|
-
});
|
|
276
|
-
if (!sourceTable.snapshotComplete) {
|
|
277
|
-
sourceTable.snapshotStatus = {
|
|
278
|
-
totalEstimatedCount: Number(sourceTableRow!.snapshot_total_estimated_count ?? -1n),
|
|
279
|
-
replicatedCount: Number(sourceTableRow!.snapshot_replicated_count ?? 0n),
|
|
280
|
-
lastKey: sourceTableRow!.snapshot_last_key
|
|
281
|
-
};
|
|
282
|
-
}
|
|
283
|
-
sourceTable.syncEvent = options.sync_rules.tableTriggersEvent(sourceTable);
|
|
284
|
-
sourceTable.syncData = options.sync_rules.tableSyncsData(sourceTable);
|
|
285
|
-
sourceTable.syncParameters = options.sync_rules.tableSyncsParameters(sourceTable);
|
|
286
|
-
|
|
287
|
-
let truncatedTables: SourceTableDecoded[] = [];
|
|
288
|
-
if (objectId != null) {
|
|
289
|
-
// relation_id present - check for renamed tables
|
|
290
|
-
truncatedTables = await db.sql`
|
|
291
|
-
SELECT
|
|
292
|
-
*
|
|
293
|
-
FROM
|
|
294
|
-
source_tables
|
|
295
|
-
WHERE
|
|
296
|
-
group_id = ${{ type: 'int4', value: group_id }}
|
|
297
|
-
AND connection_id = ${{ type: 'int4', value: connection_id }}
|
|
298
|
-
AND id != ${{ type: 'varchar', value: sourceTableRow!.id }}
|
|
299
|
-
AND (
|
|
300
|
-
relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
|
|
301
|
-
OR (
|
|
302
|
-
schema_name = ${{ type: 'varchar', value: schema }}
|
|
303
|
-
AND table_name = ${{ type: 'varchar', value: table }}
|
|
304
|
-
)
|
|
305
|
-
)
|
|
306
|
-
`
|
|
307
|
-
.decoded(models.SourceTable)
|
|
308
|
-
.rows();
|
|
309
|
-
} else {
|
|
310
|
-
// relation_id not present - only check for changed replica_id_columns
|
|
311
|
-
truncatedTables = await db.sql`
|
|
312
|
-
SELECT
|
|
313
|
-
*
|
|
314
|
-
FROM
|
|
315
|
-
source_tables
|
|
316
|
-
WHERE
|
|
317
|
-
group_id = ${{ type: 'int4', value: group_id }}
|
|
318
|
-
AND connection_id = ${{ type: 'int4', value: connection_id }}
|
|
319
|
-
AND id != ${{ type: 'varchar', value: sourceTableRow!.id }}
|
|
320
|
-
AND (
|
|
321
|
-
schema_name = ${{ type: 'varchar', value: schema }}
|
|
322
|
-
AND table_name = ${{ type: 'varchar', value: table }}
|
|
323
|
-
)
|
|
324
|
-
`
|
|
325
|
-
.decoded(models.SourceTable)
|
|
326
|
-
.rows();
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
return {
|
|
330
|
-
table: sourceTable,
|
|
331
|
-
dropTables: truncatedTables.map(
|
|
332
|
-
(doc) =>
|
|
333
|
-
new storage.SourceTable({
|
|
334
|
-
id: doc.id,
|
|
335
|
-
connectionTag: connection_tag,
|
|
336
|
-
objectId: doc.relation_id?.object_id ?? 0,
|
|
337
|
-
schema: doc.schema_name,
|
|
338
|
-
name: doc.table_name,
|
|
339
|
-
replicaIdColumns:
|
|
340
|
-
doc.replica_id_columns?.map((c) => ({
|
|
341
|
-
name: c.name,
|
|
342
|
-
typeOid: c.typeId,
|
|
343
|
-
type: c.type
|
|
344
|
-
})) ?? [],
|
|
345
|
-
snapshotComplete: doc.snapshot_done ?? true
|
|
346
|
-
})
|
|
347
|
-
)
|
|
348
|
-
};
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
|
|
352
189
|
async createWriter(options: storage.CreateWriterOptions): Promise<storage.BucketStorageBatch> {
|
|
353
190
|
const syncRules = await this.db.sql`
|
|
354
191
|
SELECT
|
|
@@ -369,7 +206,7 @@ export class PostgresSyncRulesStorage
|
|
|
369
206
|
const writer = new PostgresBucketBatch({
|
|
370
207
|
logger: options.logger ?? this.logger,
|
|
371
208
|
db: this.db,
|
|
372
|
-
sync_rules: this.sync_rules.parsed(options).
|
|
209
|
+
sync_rules: this.sync_rules.parsed(options).hydratedSyncConfig(),
|
|
373
210
|
group_id: this.group_id,
|
|
374
211
|
slot_name: this.slot_name,
|
|
375
212
|
last_checkpoint_lsn: checkpoint_lsn,
|
|
@@ -379,6 +216,7 @@ export class PostgresSyncRulesStorage
|
|
|
379
216
|
skip_existing_rows: options.skipExistingRows ?? false,
|
|
380
217
|
batch_limits: this.options.batchLimits,
|
|
381
218
|
markRecordUnavailable: options.markRecordUnavailable,
|
|
219
|
+
hooks: options.hooks,
|
|
382
220
|
storageConfig: this.storageConfig
|
|
383
221
|
});
|
|
384
222
|
this.iterateListeners((cb) => cb.batchStarted?.(writer));
|
|
@@ -402,29 +240,22 @@ export class PostgresSyncRulesStorage
|
|
|
402
240
|
checkpoint: ReplicationCheckpoint,
|
|
403
241
|
lookups: sync_rules.ScopedParameterLookup[],
|
|
404
242
|
limit: number
|
|
405
|
-
): Promise<sync_rules.
|
|
243
|
+
): Promise<sync_rules.ParameterLookupRows[]> {
|
|
406
244
|
const rows = await this.db.sql`
|
|
407
245
|
WITH
|
|
408
246
|
rows AS (
|
|
409
247
|
SELECT DISTINCT
|
|
410
|
-
ON (lookup, source_table, source_key)
|
|
411
|
-
source_table,
|
|
412
|
-
source_key,
|
|
413
|
-
id,
|
|
248
|
+
ON (lookup, source_table, source_key) requested.index - 1 AS index,
|
|
414
249
|
bucket_parameters
|
|
415
250
|
FROM
|
|
416
|
-
bucket_parameters
|
|
417
|
-
|
|
418
|
-
group_id = ${{ type: 'int4', value: this.group_id }}
|
|
419
|
-
AND lookup = ANY (
|
|
420
|
-
SELECT
|
|
421
|
-
decode((FILTER ->> 0)::text, 'hex') -- Decode the hex string to bytea
|
|
422
|
-
FROM
|
|
423
|
-
jsonb_array_elements(${{
|
|
251
|
+
bucket_parameters,
|
|
252
|
+
jsonb_array_elements(${{
|
|
424
253
|
type: 'jsonb',
|
|
425
254
|
value: lookups.map((l) => storage.serializeLookupBuffer(l).toString('hex'))
|
|
426
|
-
}}) AS
|
|
427
|
-
|
|
255
|
+
}}) WITH ORDINALITY AS requested (value, index)
|
|
256
|
+
WHERE
|
|
257
|
+
group_id = ${{ type: 'int4', value: this.group_id }}
|
|
258
|
+
AND lookup = decode((requested.value ->> 0)::text, 'hex') -- Decode the hex string to bytea
|
|
428
259
|
AND id <= ${{ type: 'int8', value: checkpoint.checkpoint }}
|
|
429
260
|
ORDER BY
|
|
430
261
|
lookup,
|
|
@@ -433,6 +264,7 @@ export class PostgresSyncRulesStorage
|
|
|
433
264
|
id DESC
|
|
434
265
|
)
|
|
435
266
|
SELECT
|
|
267
|
+
index,
|
|
436
268
|
bucket_parameters
|
|
437
269
|
FROM
|
|
438
270
|
rows
|
|
@@ -441,22 +273,34 @@ export class PostgresSyncRulesStorage
|
|
|
441
273
|
LIMIT
|
|
442
274
|
${{ type: 'int4', value: limit + 1 }}
|
|
443
275
|
`
|
|
444
|
-
.decoded(
|
|
276
|
+
.decoded(parameterSetsRow)
|
|
445
277
|
.rows();
|
|
446
278
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
.
|
|
279
|
+
let totalRows = 0;
|
|
280
|
+
const resultsByLookup = new Map<sync_rules.ScopedParameterLookup, sync_rules.SqliteJsonRow[]>();
|
|
281
|
+
for (const row of rows) {
|
|
282
|
+
const parameterRows = JSONBig.parse(row.bucket_parameters) as sync_rules.SqliteJsonRow[];
|
|
283
|
+
const lookup = lookups[Number(row.index)];
|
|
284
|
+
totalRows += parameterRows.length;
|
|
285
|
+
|
|
286
|
+
const existingResults = resultsByLookup.get(lookup);
|
|
287
|
+
if (existingResults != null) {
|
|
288
|
+
existingResults.push(...parameterRows);
|
|
289
|
+
} else {
|
|
290
|
+
resultsByLookup.set(lookup, parameterRows);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
452
293
|
|
|
453
|
-
if (
|
|
294
|
+
if (totalRows > limit) {
|
|
454
295
|
// Note that the LIMIT in the query allows more rows than parameters (because each row stores an array of
|
|
455
296
|
// parameter results). That array is very small though, and it doesn't allow fewer rows (due to the != []), so
|
|
456
297
|
// the SQL limit is good enough.
|
|
457
298
|
throw new ParameterSetLimitExceededError(limit);
|
|
458
299
|
}
|
|
459
|
-
|
|
300
|
+
|
|
301
|
+
const results: sync_rules.ParameterLookupRows[] = [];
|
|
302
|
+
resultsByLookup.forEach((rows, lookup) => results.push({ lookup, rows }));
|
|
303
|
+
return results;
|
|
460
304
|
}
|
|
461
305
|
|
|
462
306
|
async *getBucketDataBatch(
|
|
@@ -663,13 +507,16 @@ export class PostgresSyncRulesStorage
|
|
|
663
507
|
snapshot_done,
|
|
664
508
|
snapshot_lsn,
|
|
665
509
|
last_checkpoint_lsn,
|
|
666
|
-
state
|
|
510
|
+
state,
|
|
511
|
+
keepalive_op
|
|
667
512
|
FROM
|
|
668
513
|
sync_rules
|
|
669
514
|
WHERE
|
|
670
515
|
id = ${{ type: 'int4', value: this.group_id }}
|
|
671
516
|
`
|
|
672
|
-
.decoded(
|
|
517
|
+
.decoded(
|
|
518
|
+
pick(models.SyncRules, ['snapshot_done', 'last_checkpoint_lsn', 'state', 'snapshot_lsn', 'keepalive_op'])
|
|
519
|
+
)
|
|
673
520
|
.first();
|
|
674
521
|
|
|
675
522
|
if (syncRulesRow == null) {
|
|
@@ -680,7 +527,8 @@ export class PostgresSyncRulesStorage
|
|
|
680
527
|
snapshot_done: syncRulesRow.snapshot_done,
|
|
681
528
|
active: syncRulesRow.state == storage.SyncRuleState.ACTIVE,
|
|
682
529
|
checkpoint_lsn: syncRulesRow.last_checkpoint_lsn ?? null,
|
|
683
|
-
snapshot_lsn: syncRulesRow.snapshot_lsn ?? null
|
|
530
|
+
snapshot_lsn: syncRulesRow.snapshot_lsn ?? null,
|
|
531
|
+
keepalive_op: syncRulesRow.keepalive_op ?? null
|
|
684
532
|
};
|
|
685
533
|
}
|
|
686
534
|
|
|
@@ -923,7 +771,15 @@ class PostgresReplicationCheckpoint implements storage.ReplicationCheckpoint {
|
|
|
923
771
|
public readonly lsn: string | null
|
|
924
772
|
) {}
|
|
925
773
|
|
|
926
|
-
getParameterSets(
|
|
774
|
+
getParameterSets(
|
|
775
|
+
lookups: sync_rules.ScopedParameterLookup[],
|
|
776
|
+
limit: number
|
|
777
|
+
): Promise<sync_rules.ParameterLookupRows[]> {
|
|
927
778
|
return this.storage.getParameterSets(this, lookups, limit);
|
|
928
779
|
}
|
|
929
780
|
}
|
|
781
|
+
|
|
782
|
+
const parameterSetsRow = t.object({
|
|
783
|
+
index: bigint,
|
|
784
|
+
bucket_parameters: t.string
|
|
785
|
+
});
|