@powersync/service-core 1.23.3 → 1.25.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 (90) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/api/RouteAPI.d.ts +17 -3
  3. package/dist/entry/commands/compact-action.js +4 -0
  4. package/dist/entry/commands/compact-action.js.map +1 -1
  5. package/dist/replication/AbstractReplicator.d.ts +9 -4
  6. package/dist/replication/AbstractReplicator.js +35 -11
  7. package/dist/replication/AbstractReplicator.js.map +1 -1
  8. package/dist/routes/configure-fastify.d.ts +31 -0
  9. package/dist/routes/endpoints/checkpointing.d.ts +62 -0
  10. package/dist/routes/endpoints/checkpointing.js +63 -3
  11. package/dist/routes/endpoints/checkpointing.js.map +1 -1
  12. package/dist/storage/BucketStorageBatch.d.ts +4 -1
  13. package/dist/storage/BucketStorageBatch.js.map +1 -1
  14. package/dist/storage/BucketStorageFactory.d.ts +2 -2
  15. package/dist/storage/CheckpointChecksumInvalidatedError.d.ts +12 -0
  16. package/dist/storage/CheckpointChecksumInvalidatedError.js +17 -0
  17. package/dist/storage/CheckpointChecksumInvalidatedError.js.map +1 -0
  18. package/dist/storage/SourceEntity.d.ts +7 -2
  19. package/dist/storage/SourceTable.d.ts +24 -1
  20. package/dist/storage/SourceTable.js +34 -6
  21. package/dist/storage/SourceTable.js.map +1 -1
  22. package/dist/storage/SourceTableReconciler.d.ts +83 -0
  23. package/dist/storage/SourceTableReconciler.js +95 -0
  24. package/dist/storage/SourceTableReconciler.js.map +1 -0
  25. package/dist/storage/SyncRulesBucketStorage.d.ts +32 -1
  26. package/dist/storage/SyncRulesBucketStorage.js +3 -0
  27. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  28. package/dist/storage/WriteCheckpointAPI.d.ts +60 -3
  29. package/dist/storage/WriteCheckpointAPI.js +33 -0
  30. package/dist/storage/WriteCheckpointAPI.js.map +1 -1
  31. package/dist/storage/implementation/BucketDefinitionMapping.d.ts +4 -4
  32. package/dist/storage/implementation/BucketDefinitionMapping.js.map +1 -1
  33. package/dist/storage/storage-index.d.ts +2 -0
  34. package/dist/storage/storage-index.js +2 -0
  35. package/dist/storage/storage-index.js.map +1 -1
  36. package/dist/sync/BucketChecksumState.d.ts +7 -0
  37. package/dist/sync/BucketChecksumState.js +33 -13
  38. package/dist/sync/BucketChecksumState.js.map +1 -1
  39. package/dist/sync/sync.js +34 -6
  40. package/dist/sync/sync.js.map +1 -1
  41. package/dist/sync/util.d.ts +5 -0
  42. package/dist/sync/util.js +9 -0
  43. package/dist/sync/util.js.map +1 -1
  44. package/dist/util/checkpointing.d.ts +1 -0
  45. package/dist/util/checkpointing.js +1 -1
  46. package/dist/util/checkpointing.js.map +1 -1
  47. package/dist/util/config/compound-config-collector.js +9 -1
  48. package/dist/util/config/compound-config-collector.js.map +1 -1
  49. package/dist/util/config/defaults.d.ts +1 -0
  50. package/dist/util/config/defaults.js +1 -0
  51. package/dist/util/config/defaults.js.map +1 -1
  52. package/dist/util/config/types.d.ts +1 -0
  53. package/dist/util/utils.d.ts +1 -1
  54. package/dist/util/utils.js +1 -1
  55. package/dist/util/utils.js.map +1 -1
  56. package/dist/util/write-checkpoint-batcher.d.ts +1 -1
  57. package/dist/util/write-checkpoint-batcher.js +18 -8
  58. package/dist/util/write-checkpoint-batcher.js.map +1 -1
  59. package/package.json +5 -5
  60. package/src/api/RouteAPI.ts +18 -3
  61. package/src/entry/commands/compact-action.ts +6 -0
  62. package/src/replication/AbstractReplicator.ts +42 -12
  63. package/src/routes/endpoints/checkpointing.ts +77 -3
  64. package/src/storage/BucketStorageBatch.ts +4 -1
  65. package/src/storage/BucketStorageFactory.ts +2 -2
  66. package/src/storage/CheckpointChecksumInvalidatedError.ts +17 -0
  67. package/src/storage/SourceEntity.ts +6 -2
  68. package/src/storage/SourceTable.ts +51 -7
  69. package/src/storage/SourceTableReconciler.ts +188 -0
  70. package/src/storage/SyncRulesBucketStorage.ts +38 -1
  71. package/src/storage/WriteCheckpointAPI.ts +108 -3
  72. package/src/storage/implementation/BucketDefinitionMapping.ts +4 -4
  73. package/src/storage/storage-index.ts +2 -0
  74. package/src/sync/BucketChecksumState.ts +38 -13
  75. package/src/sync/sync.ts +33 -7
  76. package/src/sync/util.ts +12 -0
  77. package/src/util/checkpointing.ts +2 -1
  78. package/src/util/config/compound-config-collector.ts +12 -0
  79. package/src/util/config/defaults.ts +1 -0
  80. package/src/util/config/types.ts +1 -0
  81. package/src/util/utils.ts +1 -1
  82. package/src/util/write-checkpoint-batcher.ts +30 -10
  83. package/test/src/AbstractReplicator.test.ts +147 -0
  84. package/test/src/config.test.ts +60 -0
  85. package/test/src/routes/checkpointing.test.ts +54 -0
  86. package/test/src/source-table-reconciler.test.ts +244 -0
  87. package/test/src/sync/BucketChecksumState.test.ts +57 -11
  88. package/test/src/sync/util.test.ts +13 -1
  89. package/test/src/util/checkpointing.test.ts +99 -20
  90. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,188 @@
1
+ import { ServiceAssertionError } from '@powersync/lib-services-framework';
2
+ import { isDeepStrictEqual } from 'node:util';
3
+ import { JsonValue, SourceEntityDescriptor } from './SourceEntity.js';
4
+ import { SourceTable, SourceTableCandidate, SourceTableId, sourceTableIdEquals } from './SourceTable.js';
5
+
6
+ /**
7
+ * A source connector's classification of overlapping persisted tables.
8
+ */
9
+ export interface SourceTableCandidateResolution {
10
+ /**
11
+ * Records storage can reuse. Copies may include updated source metadata.
12
+ */
13
+ compatibleTables: ReadonlyArray<SourceTableCandidate>;
14
+
15
+ /**
16
+ * Records that cannot be reused. Every candidate must appear in exactly one result list.
17
+ */
18
+ incompatibleTables: ReadonlyArray<SourceTableCandidate>;
19
+
20
+ /**
21
+ * Values for records storage creates during this resolution.
22
+ */
23
+ newTableValues: SourceTableCreateValues;
24
+ }
25
+
26
+ export interface SourceTableCreateValues {
27
+ /**
28
+ * Source metadata for new records. Null means no metadata.
29
+ */
30
+ sourceMetadata: JsonValue;
31
+ }
32
+
33
+ /**
34
+ * Input to a source-owned reconciliation callback. The callback may run inside a storage
35
+ * transaction, so it must not mutate storage or perform slow external work.
36
+ */
37
+ export interface SourceTableCandidateReconcilerInput {
38
+ /**
39
+ * Source entity being resolved.
40
+ */
41
+ source: SourceEntityDescriptor;
42
+
43
+ /**
44
+ * Persisted tables overlapping by name or object id.
45
+ */
46
+ candidates: ReadonlyArray<SourceTableCandidate>;
47
+ }
48
+
49
+ export type SourceTableCandidateReconciler = (
50
+ input: SourceTableCandidateReconcilerInput
51
+ ) => SourceTableCandidateResolution | Promise<SourceTableCandidateResolution>;
52
+
53
+ /**
54
+ * Compare replica-id columns in order.
55
+ */
56
+ export function sameReplicaIdColumns(
57
+ left: SourceTableCandidate['replicaIdColumns'],
58
+ right: SourceEntityDescriptor
59
+ ): boolean {
60
+ const target = right.replicaIdColumns;
61
+ return (
62
+ left.length == target.length &&
63
+ left.every(
64
+ (column, index) =>
65
+ column.name == target[index].name && column.type == target[index].type && column.typeId == target[index].typeId
66
+ )
67
+ );
68
+ }
69
+
70
+ /**
71
+ * Compare the shared source-table identity fields.
72
+ */
73
+ export function sourceIdentityCompatible(source: SourceEntityDescriptor, candidate: SourceTableCandidate): boolean {
74
+ return (
75
+ candidate.schema == source.schema &&
76
+ candidate.name == source.name &&
77
+ (source.objectId == null || candidate.objectId == source.objectId) &&
78
+ sameReplicaIdColumns(candidate.replicaIdColumns, source)
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Default identity-based reconciliation for connectors without source-specific metadata.
84
+ */
85
+ export const defaultSourceTableReconciler: SourceTableCandidateReconciler = ({ source, candidates }) => {
86
+ const compatibleTables: SourceTableCandidate[] = [];
87
+ const incompatibleTables: SourceTableCandidate[] = [];
88
+ for (const candidate of candidates) {
89
+ if (sourceIdentityCompatible(source, candidate)) {
90
+ compatibleTables.push(candidate);
91
+ } else {
92
+ incompatibleTables.push(candidate);
93
+ }
94
+ }
95
+ return { compatibleTables, incompatibleTables, newTableValues: { sourceMetadata: null } };
96
+ };
97
+
98
+ /**
99
+ * Check that every candidate was classified exactly once.
100
+ */
101
+ export function validateSourceTableCandidateResolution(
102
+ candidates: ReadonlyArray<SourceTableCandidate>,
103
+ resolution: SourceTableCandidateResolution
104
+ ): void {
105
+ const classifiedTables = [...resolution.compatibleTables, ...resolution.incompatibleTables];
106
+
107
+ for (const candidate of candidates) {
108
+ const classifications = classifiedTables.filter((table) => sourceTableIdEquals(table.id, candidate.id));
109
+ if (classifications.length !== 1) {
110
+ throw new ServiceAssertionError(
111
+ `Source table candidate ${candidate.id.toString()} must be classified exactly once, got ${classifications.length}`
112
+ );
113
+ }
114
+ }
115
+
116
+ for (const table of classifiedTables) {
117
+ if (!candidates.some((candidate) => sourceTableIdEquals(candidate.id, table.id))) {
118
+ throw new ServiceAssertionError(`Source table reconciliation returned unknown candidate ${table.id.toString()}`);
119
+ }
120
+ }
121
+ }
122
+
123
+ /**
124
+ * A source-metadata update to persist.
125
+ */
126
+ export interface SourceTableMetadataUpdate {
127
+ id: SourceTableId;
128
+ sourceMetadata: JsonValue;
129
+ }
130
+
131
+ /**
132
+ * Rebuild a resolution from storage-owned tables, applying only reconciler-owned metadata to
133
+ * compatible tables. All other mutable table state comes from storage.
134
+ *
135
+ * Reconciler candidates are typed as read-only, but TypeScript types provide no runtime protection:
136
+ * callback code can cast a cloned candidate and mutate it. Rematerializing by id ensures those
137
+ * mutations are not trusted even when the compile-time boundary is bypassed.
138
+ */
139
+ export function materializeSourceTableResolution(
140
+ tables: ReadonlyArray<SourceTable>,
141
+ resolution: SourceTableCandidateResolution
142
+ ): MaterializedSourceTableResolution {
143
+ const findTable = (candidate: SourceTableCandidate): SourceTable => {
144
+ const table = tables.find((table) => sourceTableIdEquals(table.id, candidate.id));
145
+ if (table == null) {
146
+ throw new ServiceAssertionError(`Source table candidate ${candidate.id.toString()} was not persisted`);
147
+ }
148
+ return table;
149
+ };
150
+ return {
151
+ compatibleTables: resolution.compatibleTables.map((candidate) =>
152
+ findTable(candidate).withSourceMetadata(candidate.sourceMetadata)
153
+ ),
154
+ incompatibleTables: resolution.incompatibleTables.map(findTable),
155
+ newTableValues: resolution.newTableValues
156
+ };
157
+ }
158
+
159
+ export interface MaterializedSourceTableResolution {
160
+ compatibleTables: SourceTable[];
161
+ incompatibleTables: SourceTable[];
162
+ newTableValues: SourceTableCreateValues;
163
+ }
164
+
165
+ /**
166
+ * Return source-metadata changes from compatible candidates, comparing metadata by value against
167
+ * the original storage-owned tables. The reconciler may mutate its isolated candidate clones, so
168
+ * those clones cannot be used as the persisted baseline.
169
+ */
170
+ export function diffSourceTableUpdates(
171
+ persistedTables: ReadonlyArray<SourceTable>,
172
+ resolution: SourceTableCandidateResolution
173
+ ): SourceTableMetadataUpdate[] {
174
+ const updates: SourceTableMetadataUpdate[] = [];
175
+ for (const resolvedTable of resolution.compatibleTables) {
176
+ const persistedTable = persistedTables.find((table) => sourceTableIdEquals(table.id, resolvedTable.id));
177
+ if (persistedTable == null) {
178
+ throw new ServiceAssertionError(
179
+ `Source table reconciliation returned unknown candidate ${resolvedTable.id.toString()}`
180
+ );
181
+ }
182
+ if (isDeepStrictEqual(persistedTable.sourceMetadata, resolvedTable.sourceMetadata)) {
183
+ continue;
184
+ }
185
+ updates.push({ id: resolvedTable.id, sourceMetadata: resolvedTable.sourceMetadata });
186
+ }
187
+ return updates;
188
+ }
@@ -14,6 +14,7 @@ import { ParsedSyncConfigSet } from './ParsedSyncConfigSet.js';
14
14
  import { ParseSyncConfigOptions } from './PersistedSyncConfigContent.js';
15
15
  import { SourceEntityDescriptor } from './SourceEntity.js';
16
16
  import { SourceTable } from './SourceTable.js';
17
+ import { SourceTableCandidateReconciler } from './SourceTableReconciler.js';
17
18
  import { StorageVersionConfig } from './StorageVersionConfig.js';
18
19
  import { SyncStorageWriteCheckpointAPI } from './WriteCheckpointAPI.js';
19
20
 
@@ -133,6 +134,13 @@ export interface SyncRulesBucketStorage
133
134
  * 1. Separate buckets.
134
135
  * 2. Limit the size of each individual chunk according to options.batchSizeLimitBytes.
135
136
  *
137
+ * The batch may not contain all data for the checkpoint, if the checkpoint is large. The caller must
138
+ * continue querying if either:
139
+ * 1. The last chunk for any bucket has has_more = true.
140
+ * 2. A SyncBucketDataBatchEnd is returned with hasMore = true.
141
+ *
142
+ * The first check can be skipped if a SyncBucketDataBatchEnd is returned with hasMore = false.
143
+ *
136
144
  * @param checkpoint the checkpoint
137
145
  * @param dataBuckets current bucket states
138
146
  * @param options batch size options
@@ -141,7 +149,7 @@ export interface SyncRulesBucketStorage
141
149
  checkpoint: ReplicationCheckpoint,
142
150
  dataBuckets: BucketDataRequest[],
143
151
  options?: BucketDataBatchOptions
144
- ): AsyncIterable<SyncBucketDataChunk>;
152
+ ): AsyncIterable<SyncBucketDataChunk | SyncBucketDataBatchEnd>;
145
153
 
146
154
  /**
147
155
  * Compute checksums for a given list of buckets.
@@ -205,6 +213,11 @@ export interface ResolveTablesOptions {
205
213
  * Source table or collection metadata discovered during snapshot or streaming.
206
214
  */
207
215
  source: SourceEntityDescriptor;
216
+ /**
217
+ * Classifies overlapping persisted tables. Defaults to identity-based reconciliation.
218
+ * This may run inside a storage transaction and must not mutate storage.
219
+ */
220
+ reconcileSourceTables?: SourceTableCandidateReconciler;
208
221
  /**
209
222
  * For tests only - custom id generator for stable ids.
210
223
  */
@@ -308,6 +321,13 @@ export interface CompactOptions {
308
321
 
309
322
  compactParameterData?: boolean;
310
323
 
324
+ /**
325
+ * Delete client-requested write checkpoints created before this time.
326
+ *
327
+ * Generated write checkpoints are not affected.
328
+ */
329
+ deleteCheckpointRequestsBefore?: Date;
330
+
311
331
  /** Minimum of 2 */
312
332
  clearBatchLimit?: number;
313
333
 
@@ -396,6 +416,9 @@ export interface TerminateOptions extends ClearStorageOptions {
396
416
  export interface BucketDataBatchOptions {
397
417
  requestHint?: BucketRequestHint;
398
418
 
419
+ /** Abort any in-progress work for this batch, including object-storage downloads. */
420
+ signal?: AbortSignal;
421
+
399
422
  /** Limit number of documents returned. Defaults to 1000. */
400
423
  limit?: number;
401
424
 
@@ -416,6 +439,20 @@ export interface SyncBucketDataChunk {
416
439
  targetOp: util.InternalOpId | null;
417
440
  }
418
441
 
442
+ export interface SyncBucketDataBatchEnd {
443
+ /**
444
+ * True if there may be more data for this checkpoint, and the caller should continue querying.
445
+ *
446
+ * This is different from `SyncBucketDataChunk.has_more`, which is per-bucket. This is a global signal for the
447
+ * entire request, and may be true even if there is no returned chunk with has_more: true.
448
+ */
449
+ hasMore: boolean;
450
+ }
451
+
452
+ export function isBatchEnd(chunk: SyncBucketDataChunk | SyncBucketDataBatchEnd): chunk is SyncBucketDataBatchEnd {
453
+ return (chunk as SyncBucketDataBatchEnd).hasMore !== undefined;
454
+ }
455
+
419
456
  export interface ReplicationCheckpoint {
420
457
  readonly checkpoint: util.InternalOpId;
421
458
  readonly lsn: string | null;
@@ -19,6 +19,18 @@ export interface BaseWriteCheckpointIdentifier {
19
19
  user_id: string;
20
20
  }
21
21
 
22
+ export interface ClientRequestedCheckpointOptions {
23
+ /**
24
+ * Supplied for client-generated checkpoint requests.
25
+ *
26
+ * If omitted, storage creates or stores a regular write checkpoint.
27
+ * If supplied for managed write checkpoints, storage only uses it when it is
28
+ * greater than the stored id for the user_id. Same or lower supplied ids are
29
+ * no-ops, and storage returns the current stored id.
30
+ */
31
+ checkpoint_request_id?: bigint;
32
+ }
33
+
22
34
  export interface CustomWriteCheckpointFilters extends BaseWriteCheckpointIdentifier {
23
35
  /**
24
36
  * Replication stream which was active when this checkpoint was created.
@@ -28,9 +40,17 @@ export interface CustomWriteCheckpointFilters extends BaseWriteCheckpointIdentif
28
40
 
29
41
  export interface BatchedCustomWriteCheckpointOptions extends BaseWriteCheckpointIdentifier {
30
42
  /**
31
- * A supplied incrementing Write Checkpoint number
43
+ * A supplied incrementing checkpoint request id. This is still named
44
+ * "write checkpoint" in storage APIs for backwards compatibility.
32
45
  */
33
46
  checkpoint: bigint;
47
+ /**
48
+ * Required when this custom checkpoint was created from a client checkpoint
49
+ * request and should be eligible for checkpoint request retention cleanup.
50
+ * Omit or set to null for persistent custom checkpoints owned by the source
51
+ * or integration.
52
+ */
53
+ checkpoint_requested_at?: Date | null;
34
54
  }
35
55
 
36
56
  export interface CustomWriteCheckpointOptions extends BatchedCustomWriteCheckpointOptions {
@@ -50,7 +70,90 @@ export interface ManagedWriteCheckpointFilters extends BaseWriteCheckpointIdenti
50
70
  heads: Record<string, string>;
51
71
  }
52
72
 
53
- export type ManagedWriteCheckpointOptions = ManagedWriteCheckpointFilters;
73
+ export type ManagedWriteCheckpointOptions = ManagedWriteCheckpointFilters & ClientRequestedCheckpointOptions;
74
+
75
+ export interface CreateManagedWriteCheckpointsResult {
76
+ /**
77
+ * Current managed checkpoint id for each full user_id after applying the batch.
78
+ */
79
+ writeCheckpoints: Map<string, bigint>;
80
+ /**
81
+ * True when the source marker must be forced so replication observes the
82
+ * stored head. Callers use this to advance the source marker once for the
83
+ * whole batch.
84
+ *
85
+ * This must be true whenever a checkpoint in the batch has not yet been
86
+ * processed by replication - both freshly created/advanced checkpoints and
87
+ * existing checkpoints matched by a stale or duplicate request that are still
88
+ * pending. Already-processed checkpoints do not need a new marker.
89
+ *
90
+ * The case this guards against is a lost source marker on a stale retry. The
91
+ * source marker is forced after the checkpoint is persisted, so the two are
92
+ * not atomic (storage and source are usually different databases). Without
93
+ * this flag, the following sequence strands the checkpoint:
94
+ *
95
+ * 1. A client-supplied checkpoint id is persisted (write checkpoint stored,
96
+ * processed_at_lsn null).
97
+ * 2. Forcing the source marker fails, so the request errors and the client
98
+ * retries with the same id.
99
+ * 3. The retry is a no-op for the stored id (monotonic, not greater), so if
100
+ * we only advanced on "a row changed" we would skip the marker.
101
+ * 4. On an idle source nothing else moves replication past the stored head,
102
+ * so the checkpoint never gets acknowledged and the client waits forever.
103
+ *
104
+ * Reporting true while the checkpoint is still pending makes the retry re-force
105
+ * the marker, which resolves the wait.
106
+ *
107
+ * Backends that do not track a per-row processed indicator may conservatively
108
+ * report true whenever any checkpoint was matched.
109
+ */
110
+ shouldAdvance: boolean;
111
+ }
112
+
113
+ export function uniqueManagedWriteCheckpoints(
114
+ checkpoints: ManagedWriteCheckpointOptions[]
115
+ ): ManagedWriteCheckpointOptions[] {
116
+ const byUser = new Map<string, ManagedWriteCheckpointOptions>();
117
+
118
+ for (const checkpoint of checkpoints) {
119
+ const existing = byUser.get(checkpoint.user_id);
120
+ // A batch can contain entries for many users, and different users may use
121
+ // different request types (generated vs client-supplied). For a single full
122
+ // user_id, though, all entries should be the same type - a batch can still
123
+ // hold multiple entries for that user (e.g. coalesced requests), so we
124
+ // collapse them to one. For client-supplied ids we keep the greatest
125
+ // requested value, so lower stale ids cannot hide the request that should
126
+ // advance storage. For generated ids the entries are equivalent, so any one
127
+ // is kept.
128
+ //
129
+ // Mixing generated and supplied entries for the same user_id is not expected;
130
+ // shouldReplaceManagedWriteCheckpoint still resolves it deterministically
131
+ // (supplied wins) if that invariant is ever violated.
132
+ if (existing == null || shouldReplaceManagedWriteCheckpoint(existing, checkpoint)) {
133
+ byUser.set(checkpoint.user_id, checkpoint);
134
+ }
135
+ }
136
+
137
+ return [...byUser.values()];
138
+ }
139
+
140
+ function shouldReplaceManagedWriteCheckpoint(
141
+ existing: ManagedWriteCheckpointOptions,
142
+ candidate: ManagedWriteCheckpointOptions
143
+ ) {
144
+ const existingRequestId = existing.checkpoint_request_id;
145
+ const candidateRequestId = candidate.checkpoint_request_id;
146
+
147
+ if (candidateRequestId == null) {
148
+ return existingRequestId == null;
149
+ }
150
+
151
+ if (existingRequestId == null) {
152
+ return true;
153
+ }
154
+
155
+ return candidateRequestId > existingRequestId;
156
+ }
54
157
 
55
158
  export type SyncStorageLastWriteCheckpointFilters = BaseWriteCheckpointIdentifier | ManagedWriteCheckpointFilters;
56
159
  export type LastWriteCheckpointFilters = CustomWriteCheckpointFilters | ManagedWriteCheckpointFilters;
@@ -58,7 +161,9 @@ export type LastWriteCheckpointFilters = CustomWriteCheckpointFilters | ManagedW
58
161
  export interface BaseWriteCheckpointAPI {
59
162
  readonly writeCheckpointMode: WriteCheckpointMode;
60
163
  setWriteCheckpointMode(mode: WriteCheckpointMode): void;
61
- createManagedWriteCheckpoints(checkpoints: ManagedWriteCheckpointOptions[]): Promise<Map<string, bigint>>;
164
+ createManagedWriteCheckpoints(
165
+ checkpoints: ManagedWriteCheckpointOptions[]
166
+ ): Promise<CreateManagedWriteCheckpointsResult>;
62
167
  }
63
168
 
64
169
  /**
@@ -10,13 +10,13 @@ import {
10
10
  SerializedParameterIndexLookupCreator,
11
11
  serializedStreamBucketDataSourceEquality,
12
12
  serializedStreamParameterIndexLookupCreatorEquality,
13
- SerializedSyncPlanV1,
13
+ SerializedSyncPlan,
14
14
  SourceTableRef,
15
15
  SyncConfigWithErrors
16
16
  } from '@powersync/service-sync-rules';
17
17
 
18
18
  export interface SerializedSyncConfigWithMapping {
19
- plan: SerializedSyncPlanV1;
19
+ plan: SerializedSyncPlan;
20
20
  mapping: SingleSyncConfigBucketDefinitionMapping;
21
21
  }
22
22
 
@@ -131,7 +131,7 @@ export class SingleSyncConfigBucketDefinitionMapping implements BucketDefinition
131
131
  */
132
132
  static constructIncrementalMappingFromSerializedPlans(
133
133
  compatibleConfigs: SerializedSyncConfigWithMapping[],
134
- newPlan: SerializedSyncPlanV1,
134
+ newPlan: SerializedSyncPlan,
135
135
  reservedMappings: SingleSyncConfigBucketDefinitionMapping[]
136
136
  ): SingleSyncConfigBucketDefinitionMapping {
137
137
  return this.constructIncrementalMappingWithChanges(compatibleConfigs, newPlan, reservedMappings).mapping;
@@ -139,7 +139,7 @@ export class SingleSyncConfigBucketDefinitionMapping implements BucketDefinition
139
139
 
140
140
  static constructIncrementalMappingWithChanges(
141
141
  compatibleConfigs: SerializedSyncConfigWithMapping[],
142
- newPlan: SerializedSyncPlanV1,
142
+ newPlan: SerializedSyncPlan,
143
143
  reservedMappings: SingleSyncConfigBucketDefinitionMapping[]
144
144
  ): IncrementalMappingResult {
145
145
  let nextBucketDefinitionId =
@@ -2,6 +2,7 @@ export * from './bson.js';
2
2
  export * from './BucketStorage.js';
3
3
  export * from './BucketStorageBatch.js';
4
4
  export * from './BucketStorageFactory.js';
5
+ export * from './CheckpointChecksumInvalidatedError.js';
5
6
  export * from './ChecksumCache.js';
6
7
  export * from './ParsedSyncConfigSet.js';
7
8
  export * from './PersistedReplicationStream.js';
@@ -12,6 +13,7 @@ export * from './ReplicationLock.js';
12
13
  export * from './ReportStorage.js';
13
14
  export * from './SourceEntity.js';
14
15
  export * from './SourceTable.js';
16
+ export * from './SourceTableReconciler.js';
15
17
  export * from './storage-metrics.js';
16
18
  export * from './StorageEngine.js';
17
19
  export * from './StorageProvider.js';
@@ -2,6 +2,7 @@ import {
2
2
  BucketParameterQuerier,
3
3
  BucketPriority,
4
4
  BucketSource,
5
+ BucketSourceType,
5
6
  HydratedSyncConfig,
6
7
  mergeBuckets,
7
8
  QuerierError,
@@ -75,6 +76,12 @@ export class BucketChecksumState {
75
76
  */
76
77
  private lastChecksums: util.ChecksumMap | null = null;
77
78
  private lastWriteCheckpoint: bigint | null = null;
79
+ /**
80
+ * The next storage checkpoint diff may be relative to a checksum-invalidated
81
+ * checkpoint that was never sent to the client. Re-check every bucket once
82
+ * instead of applying that diff to the last client-visible checksums.
83
+ */
84
+ private forceFullChecksumForNextCheckpoint = false;
78
85
  /**
79
86
  * Once we've sent the first full checkpoint line including all {@link util.Checkpoint.streams} that the user is
80
87
  * subscribed to, we keep an index of the stream names to their index in that array.
@@ -116,6 +123,10 @@ export class BucketChecksumState {
116
123
  }
117
124
  }
118
125
 
126
+ invalidateChecksumBaseline() {
127
+ this.forceFullChecksumForNextCheckpoint = true;
128
+ }
129
+
119
130
  /**
120
131
  * Build a new checkpoint line for an underlying storage checkpoint update if any buckets have changed.
121
132
  *
@@ -140,6 +151,7 @@ export class BucketChecksumState {
140
151
 
141
152
  const update = await this.parameterState.getCheckpointUpdate(next, tracer);
142
153
  const { buckets: allBuckets, updatedBuckets, usedParameterResults } = update;
154
+ const forceFullChecksum = this.forceFullChecksumForNextCheckpoint;
143
155
 
144
156
  /** Set of all buckets in this checkpoint. */
145
157
  const bucketDescriptionMap = new Map(allBuckets.map((b) => [b.bucket, b]));
@@ -164,8 +176,9 @@ export class BucketChecksumState {
164
176
  const count = bucketsByDefinition.get(definition) ?? 0;
165
177
  bucketsByDefinition.set(definition, count + 1);
166
178
  }
167
-
168
- const breakdown = formatBucketDefinitionBreakdown(bucketsByDefinition);
179
+ // Only 1 type is allowed per sync config
180
+ const bucketSourceType = this.parameterState.syncRules.bucketSourceDefinitions[0].type;
181
+ const breakdown = formatBucketDefinitionBreakdown(bucketsByDefinition, bucketSourceType);
169
182
  errorMessage += breakdown.message;
170
183
  logData.buckets_by_definition = breakdown.countsByDefinition;
171
184
 
@@ -174,7 +187,7 @@ export class BucketChecksumState {
174
187
  }
175
188
 
176
189
  let checksumMap: util.ChecksumMap;
177
- if (updatedBuckets != INVALIDATE_ALL_BUCKETS) {
190
+ if (!forceFullChecksum && updatedBuckets != INVALIDATE_ALL_BUCKETS) {
178
191
  if (this.lastChecksums == null) {
179
192
  throw new ServiceAssertionError(`Bucket diff received without existing checksums`);
180
193
  }
@@ -248,6 +261,9 @@ export class BucketChecksumState {
248
261
  diff.updatedBuckets.length == 0
249
262
  ) {
250
263
  // No changes - don't send anything to the client
264
+ if (forceFullChecksum) {
265
+ this.forceFullChecksumForNextCheckpoint = false;
266
+ }
251
267
  return null;
252
268
  }
253
269
 
@@ -402,6 +418,9 @@ export class BucketChecksumState {
402
418
  this.lastChecksums = checksumMap;
403
419
  this.lastWriteCheckpoint = writeCheckpoint;
404
420
  this.pendingBucketDownloads = pendingBucketDownloads;
421
+ if (forceFullChecksum) {
422
+ this.forceFullChecksumForNextCheckpoint = false;
423
+ }
405
424
  deferredLog();
406
425
  },
407
426
 
@@ -795,30 +814,36 @@ function logCheckpoint(
795
814
  }
796
815
 
797
816
  /**
798
- * Format a breakdown of dynamic bucket by sync stream definition.
817
+ * Format a breakdown of dynamic buckets by sync stream or legacy bucket definition.
799
818
  *
800
- * Sorts definitions by count (descending), includes the top 10, and returns both the
819
+ * Sorts definitions by count (descending), includes the top 100, and returns both the
801
820
  * formatted message string and the counts record suitable for structured log data.
802
821
  */
803
- function formatBucketDefinitionBreakdown(bucketsByDefinition: Map<string, number>): {
822
+ function formatBucketDefinitionBreakdown(
823
+ bucketsByDefinition: Map<string, number>,
824
+ bucketSourceType: BucketSourceType
825
+ ): {
804
826
  message: string;
805
827
  countsByDefinition: Record<string, number>;
806
828
  } {
807
- // Sort definitions by count (descending) and take top 10
829
+ const maxLoggedDefinitions = 100;
830
+
831
+ // Sort definitions by count (descending) and take the largest entries.
808
832
  const allSorted = Array.from(bucketsByDefinition.entries()).sort((a, b) => b[1] - a[1]);
809
- const sortedDefinitions = allSorted.slice(0, 10);
833
+ const sortedDefinitions = allSorted.slice(0, maxLoggedDefinitions);
810
834
 
811
- let message = '\Buckets by definition:';
835
+ const sourceLabel = bucketSourceType == BucketSourceType.SYNC_STREAM ? 'sync stream' : 'bucket definition';
836
+ let message = `\nBuckets by ${sourceLabel}:`;
812
837
  const countsByDefinition: Record<string, number> = {};
813
838
  for (const [definition, count] of sortedDefinitions) {
814
839
  message += `\n ${definition}: ${count}`;
815
840
  countsByDefinition[definition] = count;
816
841
  }
817
842
 
818
- if (allSorted.length > 10) {
819
- const remainingResults = allSorted.slice(10).reduce((sum, [, count]) => sum + count, 0);
820
- const remainingDefinitions = allSorted.length - 10;
821
- message += `\n ... and ${remainingResults} more results from ${remainingDefinitions} definitions`;
843
+ if (allSorted.length > maxLoggedDefinitions) {
844
+ const remainingResults = allSorted.slice(maxLoggedDefinitions).reduce((sum, [, count]) => sum + count, 0);
845
+ const remainingDefinitions = allSorted.length - maxLoggedDefinitions;
846
+ message += `\n ... and ${remainingResults} more buckets from ${remainingDefinitions} ${sourceLabel}s`;
822
847
  }
823
848
 
824
849
  return { message, countsByDefinition };
package/src/sync/sync.ts CHANGED
@@ -1,19 +1,18 @@
1
1
  import { JSONBig, JsonContainer } from '@powersync/service-jsonbig';
2
2
  import { BucketPriority, HydratedSyncConfig, ResolvedBucket, SqliteJsonValue } from '@powersync/service-sync-rules';
3
3
 
4
- import { AbortError } from 'ix/aborterror.js';
5
-
6
4
  import * as auth from '../auth/auth-index.js';
7
5
  import * as storage from '../storage/storage-index.js';
8
6
  import * as util from '../util/util-index.js';
9
7
 
10
8
  import { Logger, logger as defaultLogger } from '@powersync/lib-services-framework';
9
+ import { isBatchEnd } from '../storage/storage-index.js';
11
10
  import { mergeAsyncIterables } from '../streams/streams-index.js';
12
11
  import { PerformanceTracer, type Span } from '../tracing/PerformanceTracer.js';
13
12
  import { BucketChecksumState, CheckpointLine, type SyncCheckpointTraceCategory } from './BucketChecksumState.js';
14
13
  import { OperationsSentStats, RequestTracker, statsForBatch } from './RequestTracker.js';
15
14
  import { SyncContext } from './SyncContext.js';
16
- import { TokenStreamOptions, acquireSemaphoreAbortable, settledPromise, tokenStream } from './util.js';
15
+ import { TokenStreamOptions, acquireSemaphoreAbortable, isAbortError, settledPromise, tokenStream } from './util.js';
17
16
 
18
17
  type CheckpointTiming = Record<string, number>;
19
18
 
@@ -87,7 +86,7 @@ export async function* streamResponse(
87
86
  try {
88
87
  yield* merged;
89
88
  } catch (e) {
90
- if (e instanceof AbortError) {
89
+ if (isAbortError(e)) {
91
90
  return;
92
91
  } else {
93
92
  throw e;
@@ -149,6 +148,22 @@ async function* streamResponseInner(
149
148
  const line = await checksumState.buildNextCheckpointLine(next.value, trace.tracer);
150
149
  return { done: false, value: { checkpoint: cp, line, trace: line == null ? null : trace } };
151
150
  } catch (e) {
151
+ if (e instanceof storage.CheckpointChecksumInvalidatedError) {
152
+ // The checksum was not usable, so buildNextCheckpointLine has not advanced
153
+ // the connection state. Drop this candidate and wait for a checkpoint that
154
+ // is not split by a compaction-produced bucket-data document.
155
+ // This is different from other checkpoint_invalidated cases in that we hit
156
+ // this during checksum calculation, instead of on data read.
157
+ trace.span.end();
158
+ checksumState.invalidateChecksumBaseline();
159
+ logger.info(`checkpoint_invalidated: ${cp.checkpoint}`, {
160
+ reason: 'compacted_before_checkpoint_line',
161
+ bucket: e.bucket,
162
+ checkpoint: cp.checkpoint,
163
+ user_id: tokenPayload.userIdJson
164
+ });
165
+ return { done: false, value: { checkpoint: cp, line: null, trace: null } };
166
+ }
152
167
  // Only end the span if we error. If we return normally, we pass ownership on to the caller.
153
168
  trace.span.end();
154
169
  throw e;
@@ -220,7 +235,7 @@ async function* streamResponseInner(
220
235
  while (true) {
221
236
  const next = await settledPromise(waitForNewCheckpointLine());
222
237
  if (next.status == 'rejected') {
223
- if (next.reason instanceof AbortError) {
238
+ if (isAbortError(next.reason)) {
224
239
  checkpointResult = { result: 'invalidated', invalidationReason: 'checkpoint_cancelled' };
225
240
  } else {
226
241
  checkpointResult = { result: 'invalidated', invalidationReason: 'checkpoint_error' };
@@ -439,12 +454,23 @@ async function* bucketDataBatch(
439
454
  // Optimization: Only fetch buckets for which the checksums have changed since the last checkpoint
440
455
  // For the first batch, this will be all buckets.
441
456
  const filteredBuckets = checkpointLine.getFilteredBucketPositions(bucketsToFetch);
442
- const dataBatches = storage.getBucketDataBatch(checkpoint, filteredBuckets, { requestHint });
443
- for await (let { chunkData: r, targetOp } of dataBatches) {
457
+ const dataBatches = storage.getBucketDataBatch(checkpoint, filteredBuckets, {
458
+ requestHint,
459
+ // Checkpoint supersession is a cooperative batch handoff. Only abort
460
+ // in-flight storage work when the connection itself is closed.
461
+ signal: abort_connection
462
+ });
463
+ for await (let chunk of dataBatches) {
444
464
  // Abort in current batch if the connection is closed
445
465
  if (abort_connection.aborted) {
446
466
  return null;
447
467
  }
468
+ if (isBatchEnd(chunk)) {
469
+ // This replaces any other has_more value, since the batch end is the last chunk.
470
+ has_more = chunk.hasMore;
471
+ break;
472
+ }
473
+ const { chunkData: r, targetOp } = chunk;
448
474
  if (r.has_more) {
449
475
  has_more = true;
450
476
  }