@powersync/service-module-postgres-storage 0.16.3 → 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.
Files changed (52) hide show
  1. package/CHANGELOG.md +60 -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/migrations/scripts/1784900000000-source-metadata.d.ts +3 -0
  6. package/dist/migrations/scripts/1784900000000-source-metadata.js +22 -0
  7. package/dist/migrations/scripts/1784900000000-source-metadata.js.map +1 -0
  8. package/dist/storage/PostgresBucketStorageFactory.js +0 -2
  9. package/dist/storage/PostgresBucketStorageFactory.js.map +1 -1
  10. package/dist/storage/PostgresCompactor.d.ts +2 -0
  11. package/dist/storage/PostgresCompactor.js +20 -0
  12. package/dist/storage/PostgresCompactor.js.map +1 -1
  13. package/dist/storage/PostgresSyncRulesStorage.d.ts +16 -1
  14. package/dist/storage/PostgresSyncRulesStorage.js +200 -131
  15. package/dist/storage/PostgresSyncRulesStorage.js.map +1 -1
  16. package/dist/storage/batch/PostgresBucketBatch.d.ts +2 -20
  17. package/dist/storage/batch/PostgresBucketBatch.js +217 -240
  18. package/dist/storage/batch/PostgresBucketBatch.js.map +1 -1
  19. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.d.ts +1 -1
  20. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js +156 -36
  21. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js.map +1 -1
  22. package/dist/storage/current-data-store.d.ts +8 -2
  23. package/dist/storage/current-data-store.js +66 -11
  24. package/dist/storage/current-data-store.js.map +1 -1
  25. package/dist/types/models/SourceTable.d.ts +7 -2
  26. package/dist/types/models/SourceTable.js +6 -2
  27. package/dist/types/models/SourceTable.js.map +1 -1
  28. package/dist/types/models/WriteCheckpoint.d.ts +2 -0
  29. package/dist/types/models/WriteCheckpoint.js +5 -2
  30. package/dist/types/models/WriteCheckpoint.js.map +1 -1
  31. package/dist/utils/checkpoints.d.ts +9 -0
  32. package/dist/utils/checkpoints.js +26 -0
  33. package/dist/utils/checkpoints.js.map +1 -0
  34. package/package.json +8 -8
  35. package/src/migrations/scripts/1782950400000-checkpoint-requested-at.ts +51 -0
  36. package/src/migrations/scripts/1784900000000-source-metadata.ts +31 -0
  37. package/src/storage/PostgresBucketStorageFactory.ts +0 -3
  38. package/src/storage/PostgresCompactor.ts +24 -0
  39. package/src/storage/PostgresSyncRulesStorage.ts +219 -137
  40. package/src/storage/batch/PostgresBucketBatch.ts +227 -246
  41. package/src/storage/checkpoints/PostgresWriteCheckpointAPI.ts +165 -37
  42. package/src/storage/current-data-store.ts +66 -11
  43. package/src/types/models/SourceTable.ts +7 -2
  44. package/src/types/models/WriteCheckpoint.ts +5 -2
  45. package/src/utils/checkpoints.ts +31 -0
  46. package/test/src/__snapshots__/storage_sync.test.ts.snap +0 -582
  47. package/test/src/checkpoint_notifications.test.ts +522 -0
  48. package/test/src/storage.test.ts +214 -5
  49. package/test/src/storage_compacting.test.ts +3 -3
  50. package/test/src/storage_sync.test.ts +48 -4
  51. package/test/tsconfig.json +1 -1
  52. package/tsconfig.tsbuildinfo +1 -1
@@ -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
  }
@@ -301,19 +301,74 @@ export class PostgresCurrentDataStore {
301
301
  `.execute();
302
302
  }
303
303
 
304
- async deleteGroupRows(db: Queryable, options: { groupId: number }) {
304
+ /**
305
+ * Delete up to `limit` rows for the group, returning the number of candidate
306
+ * rows found by the scan (see PostgresSyncRulesStorage#deleteGroupBatch for
307
+ * why the scan is counted rather than the delete).
308
+ */
309
+ async deleteGroupRowsBatch(db: Queryable, options: { groupId: number; limit: number }): Promise<bigint> {
305
310
  if (this.softDeleteEnabled) {
306
- await db.sql`
307
- DELETE FROM v3_current_data
308
- WHERE
309
- group_id = ${{ type: 'int4', value: options.groupId }}
310
- `.execute();
311
+ const result = await db.sql`
312
+ WITH
313
+ batch AS (
314
+ SELECT
315
+ ctid
316
+ FROM
317
+ v3_current_data
318
+ WHERE
319
+ group_id = ${{ type: 'int4', value: options.groupId }}
320
+ LIMIT
321
+ ${{ type: 'int4', value: options.limit }}
322
+ ),
323
+ deleted AS (
324
+ DELETE FROM v3_current_data
325
+ WHERE
326
+ ctid IN (
327
+ SELECT
328
+ ctid
329
+ FROM
330
+ batch
331
+ )
332
+ RETURNING
333
+ 1
334
+ )
335
+ SELECT
336
+ COUNT(*) AS count
337
+ FROM
338
+ batch
339
+ `.first<{ count: bigint }>();
340
+ return result?.count ?? 0n;
311
341
  } else {
312
- await db.sql`
313
- DELETE FROM current_data
314
- WHERE
315
- group_id = ${{ type: 'int4', value: options.groupId }}
316
- `.execute();
342
+ const result = await db.sql`
343
+ WITH
344
+ batch AS (
345
+ SELECT
346
+ ctid
347
+ FROM
348
+ current_data
349
+ WHERE
350
+ group_id = ${{ type: 'int4', value: options.groupId }}
351
+ LIMIT
352
+ ${{ type: 'int4', value: options.limit }}
353
+ ),
354
+ deleted AS (
355
+ DELETE FROM current_data
356
+ WHERE
357
+ ctid IN (
358
+ SELECT
359
+ ctid
360
+ FROM
361
+ batch
362
+ )
363
+ RETURNING
364
+ 1
365
+ )
366
+ SELECT
367
+ COUNT(*) AS count
368
+ FROM
369
+ batch
370
+ `.first<{ count: bigint }>();
371
+ return result?.count ?? 0n;
317
372
  }
318
373
  }
319
374
 
@@ -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>;
@@ -1,10 +1,12 @@
1
+ import { framework } from '@powersync/service-core';
1
2
  import * as t from 'ts-codec';
2
3
  import { bigint, jsonb } from '../codecs.js';
3
4
 
4
5
  export const WriteCheckpoint = t.object({
5
6
  user_id: t.string,
6
7
  lsns: jsonb(t.record(t.string)),
7
- write_checkpoint: bigint
8
+ write_checkpoint: bigint,
9
+ checkpoint_requested_at: t.Null.or(framework.codecs.date)
8
10
  });
9
11
 
10
12
  export type WriteCheckpoint = t.Encoded<typeof WriteCheckpoint>;
@@ -13,7 +15,8 @@ export type WriteCheckpointDecoded = t.Decoded<typeof WriteCheckpoint>;
13
15
  export const CustomWriteCheckpoint = t.object({
14
16
  user_id: t.string,
15
17
  write_checkpoint: bigint,
16
- sync_rules_id: bigint
18
+ sync_rules_id: bigint,
19
+ checkpoint_requested_at: t.Null.or(framework.codecs.date)
17
20
  });
18
21
 
19
22
  export type CustomWriteCheckpoint = t.Encoded<typeof CustomWriteCheckpoint>;
@@ -0,0 +1,31 @@
1
+ import * as lib_postgres from '@powersync/lib-service-postgres';
2
+ import { storage } from '@powersync/service-core';
3
+ import { models } from '../types/types.js';
4
+
5
+ /**
6
+ * Gets the latest active checkpoint document.
7
+ * This is mainly exported for mocking in tests.
8
+ */
9
+ export async function getActiveCheckpointDocument({
10
+ db
11
+ }: {
12
+ db: lib_postgres.DatabaseClient;
13
+ }): Promise<models.ActiveCheckpointDecoded | null> {
14
+ return db.sql`
15
+ SELECT
16
+ id,
17
+ last_checkpoint,
18
+ last_checkpoint_lsn
19
+ FROM
20
+ sync_rules
21
+ WHERE
22
+ state = ${{ value: storage.SyncRuleState.ACTIVE, type: 'varchar' }}
23
+ OR state = ${{ value: storage.SyncRuleState.ERRORED, type: 'varchar' }}
24
+ ORDER BY
25
+ id DESC
26
+ LIMIT
27
+ 1
28
+ `
29
+ .decoded(models.ActiveCheckpoint)
30
+ .first();
31
+ }