@powersync/service-module-postgres-storage 0.16.3 → 0.17.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.d.ts +3 -0
  3. package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.js +42 -0
  4. package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.js.map +1 -0
  5. package/dist/storage/PostgresBucketStorageFactory.js +0 -2
  6. package/dist/storage/PostgresBucketStorageFactory.js.map +1 -1
  7. package/dist/storage/PostgresCompactor.d.ts +2 -0
  8. package/dist/storage/PostgresCompactor.js +20 -0
  9. package/dist/storage/PostgresCompactor.js.map +1 -1
  10. package/dist/storage/PostgresSyncRulesStorage.d.ts +16 -1
  11. package/dist/storage/PostgresSyncRulesStorage.js +200 -131
  12. package/dist/storage/PostgresSyncRulesStorage.js.map +1 -1
  13. package/dist/storage/batch/PostgresBucketBatch.d.ts +2 -20
  14. package/dist/storage/batch/PostgresBucketBatch.js +162 -151
  15. package/dist/storage/batch/PostgresBucketBatch.js.map +1 -1
  16. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.d.ts +1 -1
  17. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js +156 -36
  18. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js.map +1 -1
  19. package/dist/storage/current-data-store.d.ts +8 -2
  20. package/dist/storage/current-data-store.js +66 -11
  21. package/dist/storage/current-data-store.js.map +1 -1
  22. package/dist/types/models/WriteCheckpoint.d.ts +2 -0
  23. package/dist/types/models/WriteCheckpoint.js +5 -2
  24. package/dist/types/models/WriteCheckpoint.js.map +1 -1
  25. package/dist/utils/checkpoints.d.ts +9 -0
  26. package/dist/utils/checkpoints.js +26 -0
  27. package/dist/utils/checkpoints.js.map +1 -0
  28. package/package.json +8 -8
  29. package/src/migrations/scripts/1782950400000-checkpoint-requested-at.ts +51 -0
  30. package/src/storage/PostgresBucketStorageFactory.ts +0 -3
  31. package/src/storage/PostgresCompactor.ts +24 -0
  32. package/src/storage/PostgresSyncRulesStorage.ts +219 -137
  33. package/src/storage/batch/PostgresBucketBatch.ts +169 -154
  34. package/src/storage/checkpoints/PostgresWriteCheckpointAPI.ts +165 -37
  35. package/src/storage/current-data-store.ts +66 -11
  36. package/src/types/models/WriteCheckpoint.ts +5 -2
  37. package/src/utils/checkpoints.ts +31 -0
  38. package/test/src/__snapshots__/storage_sync.test.ts.snap +0 -582
  39. package/test/src/checkpoint_notifications.test.ts +522 -0
  40. package/test/src/storage.test.ts +214 -5
  41. package/test/src/storage_compacting.test.ts +3 -3
  42. package/test/src/storage_sync.test.ts +4 -4
  43. package/test/tsconfig.json +1 -1
  44. package/tsconfig.tsbuildinfo +1 -1
@@ -60,7 +60,6 @@ export interface PostgresBucketBatchOptions {
60
60
  * via the Postgres NOTIFY protocol.
61
61
  */
62
62
  const StatefulCheckpoint = models.ActiveCheckpoint.and(t.object({ state: t.Enum(storage.SyncRuleState) }));
63
- type StatefulCheckpointDecoded = t.Decoded<typeof StatefulCheckpoint>;
64
63
 
65
64
  const CheckpointWithStatus = StatefulCheckpoint.and(
66
65
  t.object({
@@ -465,12 +464,11 @@ export class PostgresBucketBatch
465
464
  }
466
465
  const { flushedAny } = await persistedBatch.flush(db);
467
466
  clearedError = flushedAny && !this.clearedError;
468
- if (clearedError) {
469
- // No need to clear an error more than once per batch, since an error would always result in restarting the batch.
470
- await this.clearError(db);
471
- }
472
467
  });
473
468
  if (clearedError) {
469
+ // No need to clear an error more than once per batch, since an error would always result in restarting the batch.
470
+ // Cleared outside the replication transaction - see flushInner.
471
+ await this.clearError();
474
472
  this.clearedError = true;
475
473
  }
476
474
  }
@@ -537,6 +535,10 @@ export class PostgresBucketBatch
537
535
  });
538
536
 
539
537
  if (clearedError) {
538
+ // Clear the error outside the replication transaction (plain autocommit update,
539
+ // like the keepalive), to avoid serialization conflicts on the sync_rules row
540
+ // when multiple writers flush concurrently.
541
+ await this.clearError();
540
542
  this.clearedError = true;
541
543
  }
542
544
 
@@ -562,136 +564,144 @@ export class PostgresBucketBatch
562
564
 
563
565
  const persisted_op = this.persisted_op ?? null;
564
566
 
565
- const result = await this.db.sql`
566
- WITH
567
- selected AS (
568
- SELECT
569
- id,
570
- state,
571
- last_checkpoint,
572
- last_checkpoint_lsn,
573
- snapshot_done,
574
- no_checkpoint_before,
575
- keepalive_op,
576
- (
577
- snapshot_done = TRUE
578
- AND (
579
- last_checkpoint_lsn IS NULL
580
- OR last_checkpoint_lsn <= ${{ type: 'varchar', value: lsn }}
581
- )
567
+ // Transaction failures restart replication from the last durable checkpoint.
568
+ const result = await this.db.transaction(async (db) => {
569
+ const checkpoint = await db.sql`
570
+ WITH
571
+ selected AS (
572
+ SELECT
573
+ id,
574
+ state,
575
+ last_checkpoint,
576
+ last_checkpoint_lsn,
577
+ snapshot_done,
578
+ no_checkpoint_before,
579
+ keepalive_op,
580
+ (
581
+ snapshot_done = TRUE
582
+ AND (
583
+ last_checkpoint_lsn IS NULL
584
+ OR last_checkpoint_lsn <= ${{ type: 'varchar', value: lsn }}
585
+ )
586
+ AND (
587
+ no_checkpoint_before IS NULL
588
+ OR no_checkpoint_before <= ${{ type: 'varchar', value: lsn }}
589
+ )
590
+ ) AS can_checkpoint
591
+ FROM
592
+ sync_rules
593
+ WHERE
594
+ id = ${{ type: 'int4', value: this.group_id }}
595
+ FOR UPDATE
596
+ ),
597
+ computed AS (
598
+ SELECT
599
+ selected.*,
600
+ CASE
601
+ WHEN selected.can_checkpoint THEN GREATEST(
602
+ selected.last_checkpoint,
603
+ ${{ type: 'int8', value: persisted_op }},
604
+ selected.keepalive_op,
605
+ 0
606
+ )
607
+ ELSE selected.last_checkpoint
608
+ END AS new_last_checkpoint,
609
+ CASE
610
+ WHEN selected.can_checkpoint THEN NULL
611
+ ELSE GREATEST(
612
+ selected.keepalive_op,
613
+ ${{ type: 'int8', value: persisted_op }},
614
+ 0
615
+ )
616
+ END AS new_keepalive_op
617
+ FROM
618
+ selected
619
+ ),
620
+ updated AS (
621
+ UPDATE sync_rules AS sr
622
+ SET
623
+ last_checkpoint_lsn = CASE
624
+ WHEN computed.can_checkpoint THEN ${{ type: 'varchar', value: lsn }}
625
+ ELSE sr.last_checkpoint_lsn
626
+ END,
627
+ last_checkpoint_ts = CASE
628
+ WHEN computed.can_checkpoint THEN ${{ type: 1184, value: now }}
629
+ ELSE sr.last_checkpoint_ts
630
+ END,
631
+ last_keepalive_ts = ${{ type: 1184, value: now }},
632
+ last_fatal_error = CASE
633
+ WHEN computed.can_checkpoint THEN NULL
634
+ ELSE sr.last_fatal_error
635
+ END,
636
+ keepalive_op = computed.new_keepalive_op,
637
+ last_checkpoint = computed.new_last_checkpoint,
638
+ snapshot_lsn = CASE
639
+ WHEN computed.can_checkpoint THEN NULL
640
+ ELSE sr.snapshot_lsn
641
+ END
642
+ FROM
643
+ computed
644
+ WHERE
645
+ sr.id = computed.id
582
646
  AND (
583
- no_checkpoint_before IS NULL
584
- OR no_checkpoint_before <= ${{ type: 'varchar', value: lsn }}
585
- )
586
- ) AS can_checkpoint
587
- FROM
588
- sync_rules
589
- WHERE
590
- id = ${{ type: 'int4', value: this.group_id }}
591
- FOR UPDATE
592
- ),
593
- computed AS (
594
- SELECT
595
- selected.*,
596
- CASE
597
- WHEN selected.can_checkpoint THEN GREATEST(
598
- selected.last_checkpoint,
599
- ${{ type: 'int8', value: persisted_op }},
600
- selected.keepalive_op,
601
- 0
647
+ sr.keepalive_op IS DISTINCT FROM computed.new_keepalive_op
648
+ OR sr.last_checkpoint IS DISTINCT FROM computed.new_last_checkpoint
649
+ OR ${{ type: 'bool', value: createEmptyCheckpoints }}
602
650
  )
603
- ELSE selected.last_checkpoint
604
- END AS new_last_checkpoint,
605
- CASE
606
- WHEN selected.can_checkpoint THEN NULL
607
- ELSE GREATEST(
608
- selected.keepalive_op,
609
- ${{ type: 'int8', value: persisted_op }},
610
- 0
611
- )
612
- END AS new_keepalive_op
613
- FROM
614
- selected
615
- ),
616
- updated AS (
617
- UPDATE sync_rules AS sr
618
- SET
619
- last_checkpoint_lsn = CASE
620
- WHEN computed.can_checkpoint THEN ${{ type: 'varchar', value: lsn }}
621
- ELSE sr.last_checkpoint_lsn
622
- END,
623
- last_checkpoint_ts = CASE
624
- WHEN computed.can_checkpoint THEN ${{ type: 1184, value: now }}
625
- ELSE sr.last_checkpoint_ts
626
- END,
627
- last_keepalive_ts = ${{ type: 1184, value: now }},
628
- last_fatal_error = CASE
629
- WHEN computed.can_checkpoint THEN NULL
630
- ELSE sr.last_fatal_error
631
- END,
632
- keepalive_op = computed.new_keepalive_op,
633
- last_checkpoint = computed.new_last_checkpoint,
634
- snapshot_lsn = CASE
635
- WHEN computed.can_checkpoint THEN NULL
636
- ELSE sr.snapshot_lsn
637
- END
638
- FROM
639
- computed
640
- WHERE
641
- sr.id = computed.id
642
- AND (
643
- sr.keepalive_op IS DISTINCT FROM computed.new_keepalive_op
644
- OR sr.last_checkpoint IS DISTINCT FROM computed.new_last_checkpoint
645
- OR ${{ type: 'bool', value: createEmptyCheckpoints }}
646
- )
647
- RETURNING
648
- sr.id,
649
- sr.state,
650
- sr.last_checkpoint,
651
- sr.last_checkpoint_lsn,
652
- sr.snapshot_done,
653
- sr.no_checkpoint_before,
654
- computed.can_checkpoint,
655
- computed.keepalive_op,
656
- computed.new_last_checkpoint
657
- )
658
- SELECT
659
- id,
660
- state,
661
- last_checkpoint,
662
- last_checkpoint_lsn,
663
- snapshot_done,
664
- no_checkpoint_before,
665
- can_checkpoint,
666
- keepalive_op,
667
- new_last_checkpoint,
668
- TRUE AS created_checkpoint
669
- FROM
670
- updated
671
- UNION ALL
672
- SELECT
673
- id,
674
- state,
675
- new_last_checkpoint AS last_checkpoint,
676
- last_checkpoint_lsn,
677
- snapshot_done,
678
- no_checkpoint_before,
679
- can_checkpoint,
680
- keepalive_op,
681
- new_last_checkpoint,
682
- FALSE AS created_checkpoint
683
- FROM
684
- computed
685
- WHERE
686
- NOT EXISTS (
687
- SELECT
688
- 1
689
- FROM
690
- updated
691
- )
692
- `
693
- .decoded(CheckpointWithStatus)
694
- .first();
651
+ RETURNING
652
+ sr.id,
653
+ sr.state,
654
+ sr.last_checkpoint,
655
+ sr.last_checkpoint_lsn,
656
+ sr.snapshot_done,
657
+ sr.no_checkpoint_before,
658
+ computed.can_checkpoint,
659
+ computed.keepalive_op,
660
+ computed.new_last_checkpoint
661
+ )
662
+ SELECT
663
+ id,
664
+ state,
665
+ last_checkpoint,
666
+ last_checkpoint_lsn,
667
+ snapshot_done,
668
+ no_checkpoint_before,
669
+ can_checkpoint,
670
+ keepalive_op,
671
+ new_last_checkpoint,
672
+ TRUE AS created_checkpoint
673
+ FROM
674
+ updated
675
+ UNION ALL
676
+ SELECT
677
+ id,
678
+ state,
679
+ new_last_checkpoint AS last_checkpoint,
680
+ last_checkpoint_lsn,
681
+ snapshot_done,
682
+ no_checkpoint_before,
683
+ can_checkpoint,
684
+ keepalive_op,
685
+ new_last_checkpoint,
686
+ FALSE AS created_checkpoint
687
+ FROM
688
+ computed
689
+ WHERE
690
+ NOT EXISTS (
691
+ SELECT
692
+ 1
693
+ FROM
694
+ updated
695
+ )
696
+ `
697
+ .decoded(CheckpointWithStatus)
698
+ .first();
699
+
700
+ if (checkpoint?.can_checkpoint && checkpoint.state == storage.SyncRuleState.ACTIVE) {
701
+ await notifySyncRulesUpdate(db, checkpoint);
702
+ }
703
+ return checkpoint;
704
+ });
695
705
 
696
706
  if (result == null) {
697
707
  throw new ReplicationAssertionError('Failed to update sync_rules during checkpoint');
@@ -723,10 +733,8 @@ export class PostgresBucketBatch
723
733
  });
724
734
  }
725
735
  }
726
- await this.autoActivate(lsn);
727
- await notifySyncRulesUpdate(this.db, {
736
+ await this.autoActivate(lsn, {
728
737
  id: result.id,
729
- state: result.state,
730
738
  last_checkpoint: result.last_checkpoint,
731
739
  last_checkpoint_lsn: result.last_checkpoint_lsn
732
740
  });
@@ -1033,10 +1041,8 @@ export class PostgresBucketBatch
1033
1041
  }
1034
1042
  }
1035
1043
 
1044
+ // The error itself is cleared by the caller, outside the replication transaction - see flushInner.
1036
1045
  const clearedError = didFlush && !this.clearedError;
1037
- if (clearedError) {
1038
- await this.clearError(db);
1039
- }
1040
1046
 
1041
1047
  // Don't return empty batches
1042
1048
  return {
@@ -1305,14 +1311,13 @@ export class PostgresBucketBatch
1305
1311
  *
1306
1312
  * Called on new commits.
1307
1313
  */
1308
- private async autoActivate(lsn: string): Promise<void> {
1314
+ private async autoActivate(lsn: string, checkpoint: models.ActiveCheckpointDecoded): Promise<void> {
1309
1315
  if (!this.needsActivation) {
1310
1316
  // Already activated
1311
1317
  return;
1312
1318
  }
1313
1319
 
1314
- let didActivate = false;
1315
- await this.db.transaction(async (db) => {
1320
+ const activationResult = await this.db.transaction(async (db) => {
1316
1321
  const syncRulesRow = await db.sql`
1317
1322
  SELECT
1318
1323
  state,
@@ -1346,13 +1351,18 @@ export class PostgresBucketBatch
1346
1351
  )
1347
1352
  AND id != ${{ type: 'int4', value: this.group_id }}
1348
1353
  `.execute();
1349
- didActivate = true;
1350
- this.needsActivation = false;
1354
+ await notifySyncRulesUpdate(db, checkpoint);
1355
+ return 'activated';
1351
1356
  } else if (syncRulesRow?.state != storage.SyncRuleState.PROCESSING) {
1352
- this.needsActivation = false;
1357
+ return 'not-processing';
1353
1358
  }
1359
+ return 'pending';
1354
1360
  });
1355
- if (didActivate) {
1361
+
1362
+ if (activationResult != 'pending') {
1363
+ this.needsActivation = false;
1364
+ }
1365
+ if (activationResult == 'activated') {
1356
1366
  this.logger.info(`Activated new replication stream at ${lsn}`);
1357
1367
  }
1358
1368
  }
@@ -1438,12 +1448,17 @@ export class PostgresBucketBatch
1438
1448
  * Uses Postgres' NOTIFY functionality to update different processes when the
1439
1449
  * active checkpoint has been updated.
1440
1450
  */
1441
- export const notifySyncRulesUpdate = async (db: lib_postgres.DatabaseClient, update: StatefulCheckpointDecoded) => {
1442
- if (update.state != storage.SyncRuleState.ACTIVE) {
1443
- return;
1444
- }
1445
-
1446
- await db.query({
1447
- statement: `NOTIFY ${NOTIFICATION_CHANNEL}, '${models.ActiveCheckpointNotification.encode({ active_checkpoint: update })}'`
1448
- });
1451
+ export const notifySyncRulesUpdate = async (
1452
+ db: lib_postgres.AbstractPostgresConnection,
1453
+ update: models.ActiveCheckpointDecoded
1454
+ ) => {
1455
+ const payload = models.ActiveCheckpointNotification.encode({ active_checkpoint: update });
1456
+
1457
+ await db.sql`
1458
+ SELECT
1459
+ pg_notify (
1460
+ ${{ type: 'varchar', value: NOTIFICATION_CHANNEL }},
1461
+ ${{ type: 'varchar', value: payload }}
1462
+ )
1463
+ `.execute();
1449
1464
  };
@@ -34,52 +34,170 @@ export class PostgresWriteCheckpointAPI implements storage.WriteCheckpointAPI {
34
34
 
35
35
  async createManagedWriteCheckpoints(
36
36
  checkpoints: storage.ManagedWriteCheckpointOptions[]
37
- ): Promise<Map<string, bigint>> {
37
+ ): Promise<storage.CreateManagedWriteCheckpointsResult> {
38
38
  if (this.writeCheckpointMode !== storage.WriteCheckpointMode.MANAGED) {
39
39
  throw new framework.errors.ValidationError(
40
40
  `Attempting to create a managed Write Checkpoint when the current Write Checkpoint mode is set to "${this.writeCheckpointMode}"`
41
41
  );
42
42
  }
43
43
 
44
- const uniqueCheckpoints = [...new Map(checkpoints.map((checkpoint) => [checkpoint.user_id, checkpoint])).values()];
44
+ const uniqueCheckpoints = storage.uniqueManagedWriteCheckpoints(checkpoints);
45
45
  if (uniqueCheckpoints.length == 0) {
46
- return new Map();
46
+ return { writeCheckpoints: new Map(), shouldAdvance: false };
47
47
  }
48
48
 
49
- const mappedCheckpoints = uniqueCheckpoints.map((checkpoint) => ({
50
- user_id: checkpoint.user_id,
51
- lsns: checkpoint.heads
52
- }));
49
+ const writeCheckpoints = new Map<string, bigint>();
50
+ const generatedCheckpoints = uniqueCheckpoints.filter((checkpoint) => checkpoint.checkpoint_request_id == null);
51
+ const suppliedCheckpoints = uniqueCheckpoints.filter((checkpoint) => checkpoint.checkpoint_request_id != null);
53
52
 
54
- const rows = await this.db.sql`
55
- WITH
56
- json_data AS (
53
+ if (generatedCheckpoints.length > 0) {
54
+ const mappedCheckpoints = generatedCheckpoints.map((checkpoint) => ({
55
+ user_id: checkpoint.user_id,
56
+ lsns: checkpoint.heads
57
+ }));
58
+
59
+ const generatedRows = await this.db.sql`
60
+ WITH
61
+ json_data AS (
62
+ SELECT
63
+ CHECKPOINT ->> 'user_id' AS user_id,
64
+ CHECKPOINT -> 'lsns' AS lsns
65
+ FROM
66
+ jsonb_array_elements(${{ type: 'jsonb', value: mappedCheckpoints }}) AS
67
+ CHECKPOINT
68
+ )
69
+ INSERT INTO
70
+ write_checkpoints (
71
+ user_id,
72
+ lsns,
73
+ write_checkpoint,
74
+ checkpoint_requested_at
75
+ )
76
+ SELECT
77
+ user_id,
78
+ lsns,
79
+ 1,
80
+ NULL
81
+ FROM
82
+ json_data
83
+ ON CONFLICT (user_id) DO UPDATE
84
+ SET
85
+ write_checkpoint = write_checkpoints.write_checkpoint + 1,
86
+ lsns = EXCLUDED.lsns,
87
+ checkpoint_requested_at = NULL
88
+ RETURNING
89
+ *;
90
+ `
91
+ .decoded(models.WriteCheckpoint)
92
+ .rows();
93
+
94
+ for (const row of generatedRows) {
95
+ writeCheckpoints.set(row.user_id, row.write_checkpoint);
96
+ }
97
+ }
98
+
99
+ if (suppliedCheckpoints.length > 0) {
100
+ // Supplied request ids are monotonic. Greater values update the checkpoint
101
+ // id and heads, while equal retries only refresh the retention timestamp.
102
+ // Stale requests return the stored id without changing the row.
103
+ const mappedCheckpoints = suppliedCheckpoints.map((checkpoint) => ({
104
+ user_id: checkpoint.user_id,
105
+ lsns: checkpoint.heads,
106
+ checkpoint_request_id: String(checkpoint.checkpoint_request_id)
107
+ }));
108
+
109
+ const suppliedRows = await this.db.sql`
110
+ WITH
111
+ json_data AS (
112
+ SELECT
113
+ CHECKPOINT ->> 'user_id' AS user_id,
114
+ CHECKPOINT -> 'lsns' AS lsns,
115
+ (
116
+ CHECKPOINT ->> 'checkpoint_request_id'
117
+ )::int8 AS checkpoint_request_id
118
+ FROM
119
+ jsonb_array_elements(${{ type: 'jsonb', value: mappedCheckpoints }}) AS
120
+ CHECKPOINT
121
+ )
122
+ INSERT INTO
123
+ write_checkpoints (
124
+ user_id,
125
+ lsns,
126
+ write_checkpoint,
127
+ checkpoint_requested_at
128
+ )
129
+ SELECT
130
+ user_id,
131
+ lsns,
132
+ checkpoint_request_id,
133
+ NOW()
134
+ FROM
135
+ json_data
136
+ ON CONFLICT (user_id) DO UPDATE
137
+ SET
138
+ write_checkpoint = CASE
139
+ WHEN EXCLUDED.write_checkpoint > write_checkpoints.write_checkpoint THEN EXCLUDED.write_checkpoint
140
+ ELSE write_checkpoints.write_checkpoint
141
+ END,
142
+ lsns = CASE
143
+ WHEN EXCLUDED.write_checkpoint > write_checkpoints.write_checkpoint THEN EXCLUDED.lsns
144
+ ELSE write_checkpoints.lsns
145
+ END,
146
+ checkpoint_requested_at = NOW()
147
+ WHERE
148
+ EXCLUDED.write_checkpoint >= write_checkpoints.write_checkpoint
149
+ RETURNING
150
+ *;
151
+ `
152
+ .decoded(models.WriteCheckpoint)
153
+ .rows();
154
+
155
+ for (const row of suppliedRows) {
156
+ writeCheckpoints.set(row.user_id, row.write_checkpoint);
157
+ }
158
+
159
+ // RETURNING only excludes stale requests rejected by the monotonic
160
+ // conflict condition. Those requests still need the stored id.
161
+ const returnedSuppliedUserIds = new Set(suppliedRows.map((row) => row.user_id));
162
+ const unchangedUserIds = suppliedCheckpoints
163
+ .map((checkpoint) => checkpoint.user_id)
164
+ .filter((userId) => !returnedSuppliedUserIds.has(userId));
165
+
166
+ if (unchangedUserIds.length > 0) {
167
+ const mappedUserIds = unchangedUserIds.map((user_id) => ({ user_id }));
168
+ const unchangedRows = await this.db.sql`
169
+ WITH
170
+ json_data AS (
171
+ SELECT
172
+ CHECKPOINT ->> 'user_id' AS user_id
173
+ FROM
174
+ jsonb_array_elements(${{ type: 'jsonb', value: mappedUserIds }}) AS
175
+ CHECKPOINT
176
+ )
57
177
  SELECT
58
- CHECKPOINT ->> 'user_id' AS user_id,
59
- CHECKPOINT -> 'lsns' AS lsns
178
+ write_checkpoints.*
60
179
  FROM
61
- jsonb_array_elements(${{ type: 'jsonb', value: mappedCheckpoints }}) AS
62
- CHECKPOINT
63
- )
64
- INSERT INTO
65
- write_checkpoints (user_id, lsns, write_checkpoint)
66
- SELECT
67
- user_id,
68
- lsns,
69
- 1
70
- FROM
71
- json_data
72
- ON CONFLICT (user_id) DO UPDATE
73
- SET
74
- write_checkpoint = write_checkpoints.write_checkpoint + 1,
75
- lsns = EXCLUDED.lsns
76
- RETURNING
77
- *;
78
- `
79
- .decoded(models.WriteCheckpoint)
80
- .rows();
180
+ write_checkpoints
181
+ JOIN json_data ON write_checkpoints.user_id = json_data.user_id;
182
+ `
183
+ .decoded(models.WriteCheckpoint)
184
+ .rows();
185
+
186
+ for (const row of unchangedRows) {
187
+ writeCheckpoints.set(row.user_id, row.write_checkpoint);
188
+ }
189
+ }
190
+ }
81
191
 
82
- return new Map(rows.map((row) => [row.user_id, row.write_checkpoint]));
192
+ // Postgres storage does not track a per-row processed indicator: a write
193
+ // checkpoint is considered processed at read time by comparing its lsns
194
+ // against the replicated head (see lastManagedWriteCheckpoint). We therefore
195
+ // force the source marker whenever any checkpoint was matched, which also
196
+ // covers stale or duplicate requests whose stored checkpoint may still be
197
+ // pending. Forcing a marker for an already-processed checkpoint is wasteful
198
+ // but harmless.
199
+ const shouldAdvance = generatedCheckpoints.length > 0 || suppliedCheckpoints.length > 0;
200
+ return { writeCheckpoints, shouldAdvance };
83
201
  }
84
202
 
85
203
  async lastWriteCheckpoint(filters: storage.LastWriteCheckpointFilters): Promise<bigint | null> {
@@ -155,7 +273,8 @@ export async function batchCreateCustomWriteCheckpoints(
155
273
  // Cannot encode bigint directly using JSON.stringify.
156
274
  // The ::int8 in the query below will take care of casting back to a number
157
275
  checkpoint: String(cp.checkpoint),
158
- sync_rules_id: cp.sync_rules_id
276
+ sync_rules_id: cp.sync_rules_id,
277
+ checkpoint_requested_at: cp.checkpoint_requested_at?.toISOString() ?? null
159
278
  };
160
279
  });
161
280
 
@@ -167,7 +286,12 @@ export async function batchCreateCustomWriteCheckpoints(
167
286
  CHECKPOINT
168
287
  )
169
288
  INSERT INTO
170
- custom_write_checkpoints (user_id, write_checkpoint, sync_rules_id)
289
+ custom_write_checkpoints (
290
+ user_id,
291
+ write_checkpoint,
292
+ sync_rules_id,
293
+ checkpoint_requested_at
294
+ )
171
295
  SELECT
172
296
  CHECKPOINT ->> 'user_id'::varchar,
173
297
  (
@@ -175,11 +299,15 @@ export async function batchCreateCustomWriteCheckpoints(
175
299
  )::int8,
176
300
  (
177
301
  CHECKPOINT ->> 'sync_rules_id'
178
- )::int4
302
+ )::int4,
303
+ (
304
+ CHECKPOINT ->> 'checkpoint_requested_at'
305
+ )::timestamptz
179
306
  FROM
180
307
  json_data
181
308
  ON CONFLICT (user_id, sync_rules_id) DO UPDATE
182
309
  SET
183
- write_checkpoint = EXCLUDED.write_checkpoint;
310
+ write_checkpoint = EXCLUDED.write_checkpoint,
311
+ checkpoint_requested_at = EXCLUDED.checkpoint_requested_at;
184
312
  `.execute();
185
313
  }