@powersync/service-core 1.23.3 → 1.24.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.
- package/CHANGELOG.md +28 -0
- package/dist/api/RouteAPI.d.ts +17 -3
- package/dist/entry/commands/compact-action.js +4 -0
- package/dist/entry/commands/compact-action.js.map +1 -1
- package/dist/replication/AbstractReplicator.d.ts +9 -4
- package/dist/replication/AbstractReplicator.js +35 -11
- package/dist/replication/AbstractReplicator.js.map +1 -1
- package/dist/routes/configure-fastify.d.ts +31 -0
- package/dist/routes/endpoints/checkpointing.d.ts +62 -0
- package/dist/routes/endpoints/checkpointing.js +63 -3
- package/dist/routes/endpoints/checkpointing.js.map +1 -1
- package/dist/storage/BucketStorageBatch.d.ts +4 -1
- package/dist/storage/BucketStorageBatch.js.map +1 -1
- package/dist/storage/BucketStorageFactory.d.ts +2 -2
- package/dist/storage/CheckpointChecksumInvalidatedError.d.ts +12 -0
- package/dist/storage/CheckpointChecksumInvalidatedError.js +17 -0
- package/dist/storage/CheckpointChecksumInvalidatedError.js.map +1 -0
- package/dist/storage/SyncRulesBucketStorage.d.ts +26 -1
- package/dist/storage/SyncRulesBucketStorage.js +3 -0
- package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
- package/dist/storage/WriteCheckpointAPI.d.ts +60 -3
- package/dist/storage/WriteCheckpointAPI.js +33 -0
- package/dist/storage/WriteCheckpointAPI.js.map +1 -1
- package/dist/storage/implementation/BucketDefinitionMapping.d.ts +4 -4
- package/dist/storage/implementation/BucketDefinitionMapping.js.map +1 -1
- package/dist/storage/storage-index.d.ts +1 -0
- package/dist/storage/storage-index.js +1 -0
- package/dist/storage/storage-index.js.map +1 -1
- package/dist/sync/BucketChecksumState.d.ts +7 -0
- package/dist/sync/BucketChecksumState.js +33 -13
- package/dist/sync/BucketChecksumState.js.map +1 -1
- package/dist/sync/sync.js +34 -6
- package/dist/sync/sync.js.map +1 -1
- package/dist/sync/util.d.ts +5 -0
- package/dist/sync/util.js +9 -0
- package/dist/sync/util.js.map +1 -1
- package/dist/util/checkpointing.d.ts +1 -0
- package/dist/util/checkpointing.js +1 -1
- package/dist/util/checkpointing.js.map +1 -1
- package/dist/util/config/compound-config-collector.js +9 -1
- package/dist/util/config/compound-config-collector.js.map +1 -1
- package/dist/util/config/defaults.d.ts +1 -0
- package/dist/util/config/defaults.js +1 -0
- package/dist/util/config/defaults.js.map +1 -1
- package/dist/util/config/types.d.ts +1 -0
- package/dist/util/write-checkpoint-batcher.d.ts +1 -1
- package/dist/util/write-checkpoint-batcher.js +18 -8
- package/dist/util/write-checkpoint-batcher.js.map +1 -1
- package/package.json +5 -5
- package/src/api/RouteAPI.ts +18 -3
- package/src/entry/commands/compact-action.ts +6 -0
- package/src/replication/AbstractReplicator.ts +42 -12
- package/src/routes/endpoints/checkpointing.ts +77 -3
- package/src/storage/BucketStorageBatch.ts +4 -1
- package/src/storage/BucketStorageFactory.ts +2 -2
- package/src/storage/CheckpointChecksumInvalidatedError.ts +17 -0
- package/src/storage/SyncRulesBucketStorage.ts +32 -1
- package/src/storage/WriteCheckpointAPI.ts +108 -3
- package/src/storage/implementation/BucketDefinitionMapping.ts +4 -4
- package/src/storage/storage-index.ts +1 -0
- package/src/sync/BucketChecksumState.ts +38 -13
- package/src/sync/sync.ts +33 -7
- package/src/sync/util.ts +12 -0
- package/src/util/checkpointing.ts +2 -1
- package/src/util/config/compound-config-collector.ts +12 -0
- package/src/util/config/defaults.ts +1 -0
- package/src/util/config/types.ts +1 -0
- package/src/util/write-checkpoint-batcher.ts +30 -10
- package/test/src/AbstractReplicator.test.ts +147 -0
- package/test/src/config.test.ts +60 -0
- package/test/src/routes/checkpointing.test.ts +54 -0
- package/test/src/sync/BucketChecksumState.test.ts +57 -11
- package/test/src/sync/util.test.ts +13 -1
- package/test/src/util/checkpointing.test.ts +99 -20
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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;
|
package/src/util/config/types.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
80
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
+
});
|
package/test/src/config.test.ts
CHANGED
|
@@ -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
|
+
});
|
|
@@ -1183,7 +1183,7 @@ streams:
|
|
|
1183
1183
|
).rejects.toThrow('Too many buckets: 60 (limit of 50');
|
|
1184
1184
|
|
|
1185
1185
|
// Verify error log includes breakdown
|
|
1186
|
-
expect(errorMessages[0]).toContain('
|
|
1186
|
+
expect(errorMessages[0]).toContain('(limit of 50)\nBuckets by sync stream:');
|
|
1187
1187
|
expect(errorMessages[0]).toContain('projects: 30');
|
|
1188
1188
|
expect(errorMessages[0]).toContain('tasks: 20');
|
|
1189
1189
|
expect(errorMessages[0]).toContain('comments: 10');
|
|
@@ -1197,15 +1197,60 @@ streams:
|
|
|
1197
1197
|
});
|
|
1198
1198
|
});
|
|
1199
1199
|
|
|
1200
|
-
test('
|
|
1201
|
-
|
|
1200
|
+
test('labels the bucket breakdown as bucket definitions for legacy sync rules', async () => {
|
|
1201
|
+
const legacySyncRules = SqlSyncRules.fromYaml(
|
|
1202
|
+
`
|
|
1203
|
+
bucket_definitions:
|
|
1204
|
+
projects:
|
|
1205
|
+
parameters: SELECT id FROM projects WHERE user_id = request.user_id()
|
|
1206
|
+
data: []
|
|
1207
|
+
`,
|
|
1208
|
+
{ defaultSchema: 'public' }
|
|
1209
|
+
).config.hydrate({ hydrationState: versionedHydrationState(5), sqlite: nodeSqlite(sqlite) });
|
|
1210
|
+
|
|
1211
|
+
const storage = new MockBucketChecksumStateStorage();
|
|
1212
|
+
const errorMessages: string[] = [];
|
|
1213
|
+
const state = new BucketChecksumState({
|
|
1214
|
+
syncContext: new SyncContext({
|
|
1215
|
+
maxBuckets: 50,
|
|
1216
|
+
maxParameterQueryResults: 100,
|
|
1217
|
+
maxDataFetchConcurrency: 10
|
|
1218
|
+
}),
|
|
1219
|
+
tokenPayload: new JwtPayload({ sub: 'u1' }),
|
|
1220
|
+
syncRequest,
|
|
1221
|
+
syncRules: legacySyncRules,
|
|
1222
|
+
bucketStorage: storage,
|
|
1223
|
+
logger: {
|
|
1224
|
+
info: () => {},
|
|
1225
|
+
error: (message: string) => errorMessages.push(message),
|
|
1226
|
+
warn: () => {},
|
|
1227
|
+
debug: () => {}
|
|
1228
|
+
} as any
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
await expect(
|
|
1232
|
+
state.buildNextCheckpointLine({
|
|
1233
|
+
base: storage.makeCheckpoint(1n, (lookups) => [
|
|
1234
|
+
{ lookup: lookups[0], rows: Array.from({ length: 60 }, (_, id) => ({ id })) }
|
|
1235
|
+
]),
|
|
1236
|
+
writeCheckpoint: null,
|
|
1237
|
+
update: CHECKPOINT_INVALIDATE_ALL
|
|
1238
|
+
})
|
|
1239
|
+
).rejects.toThrow('Too many buckets: 60 (limit of 50)');
|
|
1240
|
+
|
|
1241
|
+
expect(errorMessages[0]).toContain('(limit of 50)\nBuckets by bucket definition:');
|
|
1242
|
+
expect(errorMessages[0]).toContain('projects: 60');
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
test('limits bucket breakdown to top 100 sync streams', async () => {
|
|
1246
|
+
// Create 105 sync streams with dynamic parameters.
|
|
1202
1247
|
let yamlDefinitions = `
|
|
1203
1248
|
config:
|
|
1204
1249
|
edition: 3
|
|
1205
1250
|
|
|
1206
1251
|
streams:
|
|
1207
1252
|
`;
|
|
1208
|
-
for (let i = 1; i <=
|
|
1253
|
+
for (let i = 1; i <= 105; i++) {
|
|
1209
1254
|
yamlDefinitions += ` def${i}:\n`;
|
|
1210
1255
|
yamlDefinitions += ` auto_subscribe: true\n`;
|
|
1211
1256
|
yamlDefinitions += ` query: SELECT * FROM tbl WHERE b = auth.parameter('${i}')\n`;
|
|
@@ -1229,7 +1274,7 @@ streams:
|
|
|
1229
1274
|
};
|
|
1230
1275
|
|
|
1231
1276
|
const smallContext = new SyncContext({
|
|
1232
|
-
maxBuckets:
|
|
1277
|
+
maxBuckets: 100,
|
|
1233
1278
|
maxParameterQueryResults: 10,
|
|
1234
1279
|
maxDataFetchConcurrency: 10
|
|
1235
1280
|
});
|
|
@@ -1238,7 +1283,7 @@ streams:
|
|
|
1238
1283
|
syncContext: smallContext,
|
|
1239
1284
|
tokenPayload: new JwtPayload({
|
|
1240
1285
|
sub: 'u1',
|
|
1241
|
-
...Object.fromEntries(Array.from({ length:
|
|
1286
|
+
...Object.fromEntries(Array.from({ length: 106 }, (_, i) => [i.toString(), BigInt(i)]))
|
|
1242
1287
|
}),
|
|
1243
1288
|
syncRequest,
|
|
1244
1289
|
syncRules: SYNC_RULES_MANY,
|
|
@@ -1252,15 +1297,16 @@ streams:
|
|
|
1252
1297
|
writeCheckpoint: null,
|
|
1253
1298
|
update: CHECKPOINT_INVALIDATE_ALL
|
|
1254
1299
|
})
|
|
1255
|
-
).rejects.toThrow('Too many buckets:
|
|
1300
|
+
).rejects.toThrow('Too many buckets: 105 (limit of 100)');
|
|
1256
1301
|
|
|
1257
|
-
// Verify only
|
|
1302
|
+
// Verify only the first 100 are shown.
|
|
1258
1303
|
const errorMessage = errorMessages[0];
|
|
1259
|
-
expect(errorMessage).toContain('
|
|
1304
|
+
expect(errorMessage).toContain('Buckets by sync stream:');
|
|
1305
|
+
expect(errorMessage).toContain('... and 5 more buckets from 5 sync streams');
|
|
1260
1306
|
|
|
1261
|
-
// Count how many
|
|
1307
|
+
// Count how many sync streams are listed (should be exactly 100).
|
|
1262
1308
|
const defMatches = errorMessage.match(/def\d+:/g);
|
|
1263
|
-
expect(defMatches?.length).toBe(
|
|
1309
|
+
expect(defMatches?.length).toBe(100);
|
|
1264
1310
|
});
|
|
1265
1311
|
});
|
|
1266
1312
|
});
|
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
import { acquireSemaphoreAbortable } from '@/index.js';
|
|
1
|
+
import { acquireSemaphoreAbortable, isAbortError } from '@/index.js';
|
|
2
2
|
import { Semaphore, SemaphoreInterface } from 'async-mutex';
|
|
3
|
+
import { AbortError } from 'ix/aborterror.js';
|
|
3
4
|
import { describe, expect, test, vi } from 'vitest';
|
|
4
5
|
|
|
6
|
+
describe('isAbortError', () => {
|
|
7
|
+
test('recognizes ix and native abort errors', () => {
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
controller.abort();
|
|
10
|
+
|
|
11
|
+
expect(isAbortError(new AbortError())).toBe(true);
|
|
12
|
+
expect(isAbortError(controller.signal.reason)).toBe(true);
|
|
13
|
+
expect(isAbortError(new Error('not aborted'))).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
5
17
|
describe('acquireSemaphoreAbortable', () => {
|
|
6
18
|
test('can acquire', async () => {
|
|
7
19
|
const semaphore = new Semaphore(1);
|