@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
package/src/sync/util.ts CHANGED
@@ -2,6 +2,7 @@ import * as timers from 'timers/promises';
2
2
 
3
3
  import { SemaphoreInterface } from 'async-mutex';
4
4
  import { serialize } from 'bson';
5
+ import { AbortError } from 'ix/aborterror.js';
5
6
  import * as util from '../util/util-index.js';
6
7
  import { RequestTracker } from './RequestTracker.js';
7
8
 
@@ -23,6 +24,17 @@ const DEFAULT_TOKEN_STREAM_OPTIONS: TokenStreamOptions = {
23
24
  expire_warning_period: 20_000
24
25
  };
25
26
 
27
+ /**
28
+ * Recognize both ix's AbortError and native abort errors, which Node represents
29
+ * as DOMExceptions with the name "AbortError".
30
+ */
31
+ export function isAbortError(error: unknown): boolean {
32
+ return (
33
+ error instanceof AbortError ||
34
+ (typeof error == 'object' && error != null && 'name' in error && error.name === 'AbortError')
35
+ );
36
+ }
37
+
26
38
  /**
27
39
  * An iterator that periodically yields token and optionally keepalive events, and returns once the
28
40
  * provided token expiry is reached.
@@ -3,12 +3,13 @@ import { WriteCheckpointBatcher } from './write-checkpoint-batcher.js';
3
3
  export interface CreateWriteCheckpointOptions {
4
4
  userId: string | undefined;
5
5
  clientId: string | undefined;
6
+ checkpointRequestId?: bigint;
6
7
  batcher: WriteCheckpointBatcher;
7
8
  }
8
9
  export async function createWriteCheckpoint(options: CreateWriteCheckpointOptions) {
9
10
  const full_user_id = checkpointUserId(options.userId, options.clientId);
10
11
 
11
- return options.batcher.enqueue(full_user_id);
12
+ return options.batcher.enqueue(full_user_id, options.checkpointRequestId);
12
13
  }
13
14
 
14
15
  export function checkpointUserId(user_id: string | undefined, client_id: string | undefined) {
@@ -6,6 +6,7 @@ import { Base64ConfigCollector } from './collectors/impl/base64-config-collector
6
6
  import { FallbackConfigCollector } from './collectors/impl/fallback-config-collector.js';
7
7
  import { FileSystemConfigCollector } from './collectors/impl/filesystem-config-collector.js';
8
8
  import {
9
+ DEFAULT_CHECKPOINT_REQUEST_RETENTION_MINUTES,
9
10
  DEFAULT_CHECKSUM_CACHE_TTL_MINUTES,
10
11
  DEFAULT_MAX_BUCKETS_PER_CONNECTION,
11
12
  DEFAULT_MAX_CONCURRENT_CONNECTIONS,
@@ -195,6 +196,9 @@ export class CompoundConfigCollector {
195
196
  baseConfig.api?.parameters?.max_concurrent_connections ?? DEFAULT_MAX_CONCURRENT_CONNECTIONS,
196
197
  max_data_fetch_concurrency:
197
198
  baseConfig.api?.parameters?.max_data_fetch_concurrency ?? DEFAULT_MAX_DATA_FETCH_CONCURRENCY,
199
+ checkpoint_request_retention_minutes: normalizeCheckpointRequestRetentionMinutes(
200
+ baseConfig.api?.parameters?.checkpoint_request_retention_minutes
201
+ ),
198
202
  bucket_count_cache_ttl_minutes: normalizeChecksumCacheTtlMinutes(
199
203
  baseConfig.api?.parameters?.bucket_count_cache_ttl_minutes
200
204
  )
@@ -261,3 +265,11 @@ function normalizeChecksumCacheTtlMinutes(ttlMinutes: number | undefined): numbe
261
265
  }
262
266
  return normalized;
263
267
  }
268
+
269
+ function normalizeCheckpointRequestRetentionMinutes(retentionMinutes: number | undefined): number {
270
+ const normalized = retentionMinutes ?? DEFAULT_CHECKPOINT_REQUEST_RETENTION_MINUTES;
271
+ if (!Number.isFinite(normalized) || !Number.isInteger(normalized) || normalized < 1) {
272
+ throw new Error('api.parameters.checkpoint_request_retention_minutes must be a positive integer');
273
+ }
274
+ return normalized;
275
+ }
@@ -3,4 +3,5 @@ export const DEFAULT_MAX_CONCURRENT_CONNECTIONS = 200;
3
3
  export const DEFAULT_MAX_DATA_FETCH_CONCURRENCY = 10;
4
4
  export const DEFAULT_MAX_BUCKETS_PER_CONNECTION = 1000;
5
5
  export const DEFAULT_MAX_PARAMETER_QUERY_RESULTS = 1000;
6
+ export const DEFAULT_CHECKPOINT_REQUEST_RETENTION_MINUTES = 60;
6
7
  export const DEFAULT_CHECKSUM_CACHE_TTL_MINUTES = 1 * 60;
@@ -53,6 +53,7 @@ export type ResolvedPowerSyncConfig = {
53
53
  max_data_fetch_concurrency: number;
54
54
  max_buckets_per_connection: number;
55
55
  max_parameter_query_results: number;
56
+ checkpoint_request_retention_minutes: number;
56
57
  bucket_count_cache_ttl_minutes: number;
57
58
  };
58
59
 
package/src/util/utils.ts CHANGED
@@ -33,7 +33,7 @@ export interface PartialChecksum {
33
33
  */
34
34
  export type InternalOpId = bigint;
35
35
 
36
- export const ID_NAMESPACE = 'a396dd91-09fc-4017-a28d-3df722f651e9';
36
+ export const ID_NAMESPACE = uuid.parse('a396dd91-09fc-4017-a28d-3df722f651e9');
37
37
 
38
38
  export function escapeIdentifier(identifier: string) {
39
39
  return `"${identifier.replace(/"/g, '""').replace(/\./g, '"."')}"`;
@@ -1,6 +1,11 @@
1
1
  import { ErrorCode, ServiceAssertionError, ServiceError } from '@powersync/lib-services-framework';
2
2
  import { RouteAPI } from '../api/RouteAPI.js';
3
- import { BucketStorageFactory, SyncRulesBucketStorage } from '../storage/storage-index.js';
3
+ import {
4
+ BucketStorageFactory,
5
+ CreateManagedWriteCheckpointsResult,
6
+ ManagedWriteCheckpointOptions,
7
+ SyncRulesBucketStorage
8
+ } from '../storage/storage-index.js';
4
9
 
5
10
  // Keep up to three source-head/storage batches executing concurrently under load.
6
11
  // There is intentionally no explicit batch-size cap here: the HTTP request queue
@@ -16,6 +21,7 @@ export interface CreateWriteCheckpointResult {
16
21
 
17
22
  interface QueuedWriteCheckpoint {
18
23
  userId: string;
24
+ checkpointRequestId: bigint | undefined;
19
25
  resolvers: PromiseWithResolvers<CreateWriteCheckpointResult>;
20
26
  }
21
27
 
@@ -29,9 +35,9 @@ export class WriteCheckpointBatcher {
29
35
  private readonly getStorage: () => BucketStorageFactory
30
36
  ) {}
31
37
 
32
- enqueue(userId: string): Promise<CreateWriteCheckpointResult> {
38
+ enqueue(userId: string, checkpointRequestId?: bigint): Promise<CreateWriteCheckpointResult> {
33
39
  const resolvers = Promise.withResolvers<CreateWriteCheckpointResult>();
34
- this.pending.push({ userId, resolvers });
40
+ this.pending.push({ userId, checkpointRequestId, resolvers });
35
41
  this.schedulePump();
36
42
  return resolvers.promise;
37
43
  }
@@ -74,10 +80,18 @@ export class WriteCheckpointBatcher {
74
80
  throw new ServiceError(ErrorCode.PSYNC_S2302, `Cannot create Write Checkpoint since no sync config is active.`);
75
81
  }
76
82
 
83
+ // The source adapter reads the head, hands it to this callback to persist the
84
+ // write-checkpoint mapping, then forces a source marker only when storage
85
+ // reports an advance. Keeping the marker inside the callback means it is
86
+ // causally ordered after the head within a single source session.
77
87
  const { writeCheckpoints, currentCheckpoint } = await this.getAPI().createReplicationHead(
78
88
  async (currentCheckpoint) => {
79
- const writeCheckpoints = await this.createBatchWriteCheckpoints(syncBucketStorage, batch, currentCheckpoint);
80
- return { writeCheckpoints, currentCheckpoint };
89
+ const { writeCheckpoints, shouldAdvance } = await this.createBatchWriteCheckpoints(
90
+ syncBucketStorage,
91
+ batch,
92
+ currentCheckpoint
93
+ );
94
+ return { response: { writeCheckpoints, currentCheckpoint }, shouldAdvance };
81
95
  }
82
96
  );
83
97
 
@@ -111,12 +125,18 @@ export class WriteCheckpointBatcher {
111
125
  syncBucketStorage: SyncRulesBucketStorage,
112
126
  batch: QueuedWriteCheckpoint[],
113
127
  currentCheckpoint: string
114
- ) {
128
+ ): Promise<CreateManagedWriteCheckpointsResult> {
115
129
  return syncBucketStorage.createManagedWriteCheckpoints(
116
- batch.map((request) => ({
117
- user_id: request.userId,
118
- heads: { '1': currentCheckpoint }
119
- }))
130
+ batch.map((request) => {
131
+ const checkpoint: ManagedWriteCheckpointOptions = {
132
+ user_id: request.userId,
133
+ heads: { '1': currentCheckpoint }
134
+ };
135
+ if (request.checkpointRequestId != null) {
136
+ checkpoint.checkpoint_request_id = request.checkpointRequestId;
137
+ }
138
+ return checkpoint;
139
+ })
120
140
  );
121
141
  }
122
142
  }
@@ -0,0 +1,147 @@
1
+ import { AbstractReplicationJob } from '@/replication/AbstractReplicationJob.js';
2
+ import { AbstractReplicator, AbstractReplicatorOptions, CreateJobOptions } from '@/replication/AbstractReplicator.js';
3
+ import { PersistedReplicationStream } from '@/storage/PersistedReplicationStream.js';
4
+ import { SyncRulesBucketStorage } from '@/storage/SyncRulesBucketStorage.js';
5
+ import { describe, expect, it, vi } from 'vitest';
6
+
7
+ class TestReplicator extends AbstractReplicator {
8
+ constructor(
9
+ private readonly cleanup: (storage: SyncRulesBucketStorage) => Promise<void>,
10
+ options: AbstractReplicatorOptions = {
11
+ id: 'test',
12
+ storageEngine: {} as AbstractReplicatorOptions['storageEngine'],
13
+ metricsEngine: {} as AbstractReplicatorOptions['metricsEngine'],
14
+ syncRuleProvider: {} as AbstractReplicatorOptions['syncRuleProvider'],
15
+ rateLimiter: {} as AbstractReplicatorOptions['rateLimiter']
16
+ }
17
+ ) {
18
+ super(options);
19
+ }
20
+
21
+ createJob(_options: CreateJobOptions): AbstractReplicationJob {
22
+ throw new Error('Not implemented');
23
+ }
24
+
25
+ cleanUp(storage: SyncRulesBucketStorage): Promise<void> {
26
+ return this.cleanup(storage);
27
+ }
28
+
29
+ async testConnection() {
30
+ return { connectionDescription: 'test' };
31
+ }
32
+
33
+ terminateStoppedStream(
34
+ replicationStream: PersistedReplicationStream,
35
+ syncRuleStorage: SyncRulesBucketStorage
36
+ ): Promise<void> {
37
+ return this.terminateStoppedReplicationStream(replicationStream, syncRuleStorage);
38
+ }
39
+
40
+ addClearingJob(replicationStreamId: number, promise: Promise<void>): void {
41
+ this.clearingJobs.set(replicationStreamId, promise);
42
+ }
43
+
44
+ get heartbeatIntervalNanosForTest(): bigint | null {
45
+ return (this as any).heartbeatIntervalNanos;
46
+ }
47
+ }
48
+
49
+ describe('AbstractReplicator heartbeat interval', () => {
50
+ const options: AbstractReplicatorOptions = {
51
+ id: 'test',
52
+ storageEngine: {} as AbstractReplicatorOptions['storageEngine'],
53
+ metricsEngine: {} as AbstractReplicatorOptions['metricsEngine'],
54
+ syncRuleProvider: {} as AbstractReplicatorOptions['syncRuleProvider'],
55
+ rateLimiter: {} as AbstractReplicatorOptions['rateLimiter']
56
+ };
57
+
58
+ it.each([undefined, null])('uses the default for %s', (heartbeatIntervalSeconds) => {
59
+ const replicator = new TestReplicator(async () => {}, { ...options, heartbeatIntervalSeconds });
60
+
61
+ expect(replicator.heartbeatIntervalNanosForTest).toBe(60_000_000_000n);
62
+ });
63
+
64
+ it('disables the heartbeat interval with 0', () => {
65
+ const replicator = new TestReplicator(async () => {}, { ...options, heartbeatIntervalSeconds: 0 });
66
+
67
+ expect(replicator.heartbeatIntervalNanosForTest).toBeNull();
68
+ });
69
+
70
+ it('converts a positive heartbeat interval to nanoseconds', () => {
71
+ const replicator = new TestReplicator(async () => {}, { ...options, heartbeatIntervalSeconds: 5 });
72
+
73
+ expect(replicator.heartbeatIntervalNanosForTest).toBe(5_000_000_000n);
74
+ });
75
+ });
76
+
77
+ describe('AbstractReplicator stopped stream cleanup', () => {
78
+ it('holds the replication stream lock across source and storage cleanup', async () => {
79
+ const calls: string[] = [];
80
+ const release = vi.fn(async () => {
81
+ calls.push('release');
82
+ });
83
+ const replicationStream = {
84
+ async lock() {
85
+ calls.push('lock');
86
+ return { sync_rules_id: 1, release };
87
+ }
88
+ } as unknown as PersistedReplicationStream;
89
+ const syncRuleStorage = {
90
+ logger: { info: vi.fn() },
91
+ async terminate() {
92
+ calls.push('terminate');
93
+ }
94
+ } as unknown as SyncRulesBucketStorage;
95
+ const replicator = new TestReplicator(async () => {
96
+ calls.push('cleanup');
97
+ });
98
+
99
+ await replicator.terminateStoppedStream(replicationStream, syncRuleStorage);
100
+
101
+ expect(calls).toEqual(['lock', 'cleanup', 'terminate', 'release']);
102
+ expect(release).toHaveBeenCalledOnce();
103
+ });
104
+
105
+ it('releases the replication stream lock when cleanup fails', async () => {
106
+ const cleanupError = new Error('cleanup failed');
107
+ const release = vi.fn(async () => {});
108
+ const replicationStream = {
109
+ async lock() {
110
+ return { sync_rules_id: 1, release };
111
+ }
112
+ } as unknown as PersistedReplicationStream;
113
+ const terminate = vi.fn(async () => {});
114
+ const syncRuleStorage = {
115
+ logger: { info: vi.fn() },
116
+ terminate
117
+ } as unknown as SyncRulesBucketStorage;
118
+ const replicator = new TestReplicator(async () => {
119
+ throw cleanupError;
120
+ });
121
+
122
+ await expect(replicator.terminateStoppedStream(replicationStream, syncRuleStorage)).rejects.toBe(cleanupError);
123
+
124
+ expect(terminate).not.toHaveBeenCalled();
125
+ expect(release).toHaveBeenCalledOnce();
126
+ });
127
+
128
+ it('waits for stopped stream cleanup when stopping', async () => {
129
+ let finishCleanup: () => void;
130
+ const cleanup = new Promise<void>((resolve) => {
131
+ finishCleanup = resolve;
132
+ });
133
+ const replicator = new TestReplicator(async () => {});
134
+ replicator.addClearingJob(1, cleanup);
135
+
136
+ let stopped = false;
137
+ const stop = replicator.stop().then(() => {
138
+ stopped = true;
139
+ });
140
+ await Promise.resolve();
141
+ expect(stopped).toBe(false);
142
+
143
+ finishCleanup!();
144
+ await stop;
145
+ expect(stopped).toBe(true);
146
+ });
147
+ });
@@ -70,6 +70,66 @@ describe('Config', () => {
70
70
  expect(config.api_parameters.max_buckets_per_connection).toBe(1);
71
71
  });
72
72
 
73
+ it('should resolve checkpoint request retention config', {}, async () => {
74
+ const yamlConfig = /* yaml */ `
75
+ # PowerSync config
76
+ replication:
77
+ connections: []
78
+ storage:
79
+ type: mongodb
80
+ api:
81
+ parameters:
82
+ checkpoint_request_retention_minutes: 15
83
+ `;
84
+
85
+ const collector = new CompoundConfigCollector();
86
+
87
+ const config = await collector.collectConfig({
88
+ config_base64: Buffer.from(yamlConfig, 'utf-8').toString('base64')
89
+ });
90
+
91
+ expect(config.api_parameters.checkpoint_request_retention_minutes).toBe(15);
92
+ });
93
+
94
+ it('should default checkpoint request retention to 60 minutes', {}, async () => {
95
+ const yamlConfig = /* yaml */ `
96
+ # PowerSync config
97
+ replication:
98
+ connections: []
99
+ storage:
100
+ type: mongodb
101
+ `;
102
+
103
+ const collector = new CompoundConfigCollector();
104
+
105
+ const config = await collector.collectConfig({
106
+ config_base64: Buffer.from(yamlConfig, 'utf-8').toString('base64')
107
+ });
108
+
109
+ expect(config.api_parameters.checkpoint_request_retention_minutes).toBe(60);
110
+ });
111
+
112
+ it.each([0, -1, 1.5])('should reject checkpoint request retention of %s minutes', async (retentionMinutes) => {
113
+ const yamlConfig = /* yaml */ `
114
+ # PowerSync config
115
+ replication:
116
+ connections: []
117
+ storage:
118
+ type: mongodb
119
+ api:
120
+ parameters:
121
+ checkpoint_request_retention_minutes: ${retentionMinutes}
122
+ `;
123
+
124
+ const collector = new CompoundConfigCollector();
125
+
126
+ await expect(
127
+ collector.collectConfig({
128
+ config_base64: Buffer.from(yamlConfig, 'utf-8').toString('base64')
129
+ })
130
+ ).rejects.toThrow('api.parameters.checkpoint_request_retention_minutes must be a positive integer');
131
+ });
132
+
73
133
  it('should resolve checksum cache TTL from API parameters', async () => {
74
134
  const yamlConfig = /* yaml */ `
75
135
  # PowerSync config
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { CheckpointRequestPayload, checkpointRequest } from '../../../src/routes/endpoints/checkpointing.js';
3
+
4
+ describe('checkpoint request route', () => {
5
+ const payload = (checkpointRequestId: string | number | bigint) => ({
6
+ client_id: 'client-a',
7
+ checkpoint_request_id: checkpointRequestId
8
+ });
9
+
10
+ it.each([
11
+ ['safe JSON number', 1, 1n],
12
+ ['max int64 string', '9223372036854775807', 9_223_372_036_854_775_807n],
13
+ ['bigint value', 42n, 42n]
14
+ ])('decodes a positive int64 checkpoint request id from a %s', (_description, checkpointRequestId, expected) => {
15
+ expect(CheckpointRequestPayload.decode(payload(checkpointRequestId)).checkpoint_request_id).toEqual(expected);
16
+ });
17
+
18
+ it.each([1, '9223372036854775807'])(
19
+ 'accepts API payloads with positive int64 checkpoint request id %s',
20
+ (checkpointRequestId) => {
21
+ expect(checkpointRequest.validator!.validate(payload(checkpointRequestId)).valid).toBe(true);
22
+ }
23
+ );
24
+
25
+ it.each([
26
+ 0,
27
+ -1,
28
+ '0',
29
+ '-1',
30
+ '9223372036854775808',
31
+ '999999999999999999999999999999999999999999999',
32
+ Number.MAX_SAFE_INTEGER + 1
33
+ ])('rejects invalid API checkpoint request id %s', (checkpointRequestId) => {
34
+ expect(checkpointRequest.validator!.validate(payload(checkpointRequestId)).valid).toBe(false);
35
+ });
36
+
37
+ it('returns the stored checkpoint request id from the checkpoint request route', async () => {
38
+ const writeCheckpointBatcher = {
39
+ enqueue: vi.fn(async () => ({ replicationHead: 'head-1', writeCheckpoint: '43' }))
40
+ };
41
+
42
+ await expect(
43
+ checkpointRequest.handler({
44
+ context: {
45
+ token_payload: { userIdString: 'user-a' },
46
+ service_context: { writeCheckpointBatcher }
47
+ },
48
+ params: payload(42)
49
+ } as any)
50
+ ).resolves.toEqual({ checkpoint_request_id: '43' });
51
+
52
+ expect(writeCheckpointBatcher.enqueue).toHaveBeenCalledWith('user-a/client-a', 42n);
53
+ });
54
+ });