@powersync/service-core 1.25.0 → 1.26.1

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 (114) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/api/diagnostics.js +2 -0
  3. package/dist/api/diagnostics.js.map +1 -1
  4. package/dist/entry/commands/compact-action.js +28 -24
  5. package/dist/entry/commands/compact-action.js.map +1 -1
  6. package/dist/metrics/metrics-interfaces.d.ts +5 -1
  7. package/dist/metrics/metrics-interfaces.js.map +1 -1
  8. package/dist/metrics/open-telemetry/MetricAttributeFilteringExporter.d.ts +36 -0
  9. package/dist/metrics/open-telemetry/MetricAttributeFilteringExporter.js +70 -0
  10. package/dist/metrics/open-telemetry/MetricAttributeFilteringExporter.js.map +1 -0
  11. package/dist/metrics/open-telemetry/OpenTelemetryMetricsFactory.js +6 -1
  12. package/dist/metrics/open-telemetry/OpenTelemetryMetricsFactory.js.map +1 -1
  13. package/dist/metrics/open-telemetry/util.js +7 -3
  14. package/dist/metrics/open-telemetry/util.js.map +1 -1
  15. package/dist/replication/AbstractReplicator.js +16 -6
  16. package/dist/replication/AbstractReplicator.js.map +1 -1
  17. package/dist/routes/configure-fastify.d.ts +9 -0
  18. package/dist/routes/endpoints/admin.d.ts +18 -0
  19. package/dist/routes/endpoints/admin.js +3 -1
  20. package/dist/routes/endpoints/admin.js.map +1 -1
  21. package/dist/routes/endpoints/sync-rules.js +3 -1
  22. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  23. package/dist/storage/BucketStorageBatch.d.ts +2 -1
  24. package/dist/storage/BucketStorageBatch.js.map +1 -1
  25. package/dist/storage/BucketStorageFactory.d.ts +28 -4
  26. package/dist/storage/BucketStorageFactory.js +8 -4
  27. package/dist/storage/BucketStorageFactory.js.map +1 -1
  28. package/dist/storage/PersistedSyncConfigContent.d.ts +2 -0
  29. package/dist/storage/PersistedSyncConfigContent.js +23 -10
  30. package/dist/storage/PersistedSyncConfigContent.js.map +1 -1
  31. package/dist/storage/ReplicationEventPayload.d.ts +6 -1
  32. package/dist/storage/SourceTable.d.ts +13 -5
  33. package/dist/storage/SourceTable.js +8 -4
  34. package/dist/storage/SourceTable.js.map +1 -1
  35. package/dist/storage/StorageVersionConfig.d.ts +4 -0
  36. package/dist/storage/StorageVersionConfig.js +10 -1
  37. package/dist/storage/StorageVersionConfig.js.map +1 -1
  38. package/dist/storage/SyncRulesBucketStorage.d.ts +39 -7
  39. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  40. package/dist/storage/WriteCheckpointAPI.d.ts +25 -3
  41. package/dist/storage/WriteCheckpointAPI.js.map +1 -1
  42. package/dist/storage/implementation/BucketDefinitionMapping.d.ts +23 -7
  43. package/dist/storage/implementation/BucketDefinitionMapping.js +88 -14
  44. package/dist/storage/implementation/BucketDefinitionMapping.js.map +1 -1
  45. package/dist/storage/implementation/IncrementalReprocessingSyncConfigLog.js +9 -1
  46. package/dist/storage/implementation/IncrementalReprocessingSyncConfigLog.js.map +1 -1
  47. package/dist/storage/storage-metrics.js +120 -4
  48. package/dist/storage/storage-metrics.js.map +1 -1
  49. package/dist/sync/BucketChecksumState.d.ts +1 -1
  50. package/dist/sync/BucketChecksumState.js.map +1 -1
  51. package/dist/sync/sync.js +37 -9
  52. package/dist/sync/sync.js.map +1 -1
  53. package/dist/sync/util.d.ts +1 -1
  54. package/dist/sync/util.js +13 -1
  55. package/dist/sync/util.js.map +1 -1
  56. package/dist/util/config/compound-config-collector.js +5 -0
  57. package/dist/util/config/compound-config-collector.js.map +1 -1
  58. package/dist/util/config/sync-rules/impl/base64-sync-rules-collector.js +1 -0
  59. package/dist/util/config/sync-rules/impl/base64-sync-rules-collector.js.map +1 -1
  60. package/dist/util/config/sync-rules/impl/filesystem-sync-rules-collector.js +1 -0
  61. package/dist/util/config/sync-rules/impl/filesystem-sync-rules-collector.js.map +1 -1
  62. package/dist/util/config/sync-rules/sync-rules-provider.d.ts +2 -0
  63. package/dist/util/config/sync-rules/sync-rules-provider.js +3 -0
  64. package/dist/util/config/sync-rules/sync-rules-provider.js.map +1 -1
  65. package/dist/util/config/types.d.ts +1 -0
  66. package/dist/util/protocol-types.d.ts +4 -4
  67. package/dist/util/protocol-types.js +3 -2
  68. package/dist/util/protocol-types.js.map +1 -1
  69. package/dist/util/utils.d.ts +1 -0
  70. package/dist/util/utils.js +15 -0
  71. package/dist/util/utils.js.map +1 -1
  72. package/package.json +6 -5
  73. package/src/api/diagnostics.ts +2 -0
  74. package/src/entry/commands/compact-action.ts +32 -24
  75. package/src/metrics/metrics-interfaces.ts +6 -1
  76. package/src/metrics/open-telemetry/MetricAttributeFilteringExporter.ts +85 -0
  77. package/src/metrics/open-telemetry/OpenTelemetryMetricsFactory.ts +7 -2
  78. package/src/metrics/open-telemetry/util.ts +7 -3
  79. package/src/replication/AbstractReplicator.ts +22 -6
  80. package/src/routes/endpoints/admin.ts +3 -1
  81. package/src/routes/endpoints/sync-rules.ts +3 -1
  82. package/src/storage/BucketStorageBatch.ts +2 -1
  83. package/src/storage/BucketStorageFactory.ts +44 -8
  84. package/src/storage/PersistedSyncConfigContent.ts +26 -11
  85. package/src/storage/ReplicationEventPayload.ts +6 -1
  86. package/src/storage/SourceTable.ts +17 -4
  87. package/src/storage/StorageVersionConfig.ts +11 -1
  88. package/src/storage/SyncRulesBucketStorage.ts +47 -7
  89. package/src/storage/WriteCheckpointAPI.ts +32 -3
  90. package/src/storage/implementation/BucketDefinitionMapping.ts +120 -17
  91. package/src/storage/implementation/IncrementalReprocessingSyncConfigLog.ts +22 -5
  92. package/src/storage/storage-metrics.ts +154 -8
  93. package/src/sync/BucketChecksumState.ts +3 -1
  94. package/src/sync/sync.ts +40 -11
  95. package/src/sync/util.ts +25 -9
  96. package/src/util/config/compound-config-collector.ts +6 -0
  97. package/src/util/config/sync-rules/impl/base64-sync-rules-collector.ts +1 -0
  98. package/src/util/config/sync-rules/impl/filesystem-sync-rules-collector.ts +1 -0
  99. package/src/util/config/sync-rules/sync-rules-provider.ts +5 -0
  100. package/src/util/config/types.ts +1 -0
  101. package/src/util/protocol-types.ts +3 -2
  102. package/src/util/utils.ts +14 -0
  103. package/test/src/AbstractReplicator.test.ts +30 -0
  104. package/test/src/MetricAttributeFilteringExporter.test.ts +96 -0
  105. package/test/src/PersistedSyncConfigContent.test.ts +152 -0
  106. package/test/src/config.test.ts +40 -0
  107. package/test/src/diagnostics.test.ts +25 -0
  108. package/test/src/routes/admin.test.ts +8 -2
  109. package/test/src/routes/stream.test.ts +123 -2
  110. package/test/src/storage/SourceTable.test.ts +4 -1
  111. package/test/src/storage-metrics.test.ts +148 -0
  112. package/test/src/sync/util.test.ts +34 -0
  113. package/test/src/util/utils.test.ts +23 -0
  114. package/tsconfig.tsbuildinfo +1 -1
@@ -34,6 +34,7 @@ describe('admin routes', () => {
34
34
  syncConfigId?: string;
35
35
  active?: boolean;
36
36
  content?: string;
37
+ version_label?: string;
37
38
  }): storage.PersistedSyncConfigContent {
38
39
  const id = options.id ?? 1;
39
40
  const syncConfigId = options.syncConfigId ?? String(id);
@@ -66,6 +67,7 @@ bucket_definitions:
66
67
  compiled_plan: null,
67
68
  storageVersion: storage.LEGACY_STORAGE_VERSION,
68
69
  syncConfigState: syncConfigStatus.state,
70
+ version_label: options.version_label,
69
71
  parsed(options?: any) {
70
72
  const syncRules = SqlSyncRules.fromYaml(content.sync_rules_content, {
71
73
  ...options,
@@ -113,7 +115,7 @@ bucket_definitions:
113
115
 
114
116
  describe('diagnostics', () => {
115
117
  it('returns deploying config status', async () => {
116
- const active = makeSyncConfigContent({ id: 1, syncConfigId: 'active-config' });
118
+ const active = makeSyncConfigContent({ id: 1, syncConfigId: 'active-config', version_label: 'v5' });
117
119
  const deploying = makeSyncConfigContent({ id: 2, syncConfigId: 'deploying-config', active: false });
118
120
  const getInstance = vi.fn(() => ({
119
121
  async getStatus() {
@@ -144,12 +146,13 @@ bucket_definitions:
144
146
 
145
147
  expect(response.deploying_sync_rules?.connections[0].slot_name).toBe('slot_2');
146
148
  expect(response.active_sync_rules?.connections[0].slot_name).toBe('slot_1');
149
+ expect(response.active_sync_rules?.version_label).toBe('v5');
147
150
  });
148
151
  });
149
152
 
150
153
  describe('reprocess', () => {
151
154
  it('reprocesses the active sync config', async () => {
152
- const active = makeSyncConfigContent({ id: 7, syncConfigId: 'active-config' });
155
+ const active = makeSyncConfigContent({ id: 7, syncConfigId: 'active-config', version_label: 'v6' });
153
156
  const updateSyncRules = vi.fn(async () => ({
154
157
  replicationStreamId: 8,
155
158
  replicationStreamName: 'new_slot',
@@ -178,6 +181,9 @@ bucket_definitions:
178
181
  expect(activeBucketStorage.getActiveSyncConfig).toHaveBeenCalledTimes(1);
179
182
  expect(activeBucketStorage.getSyncConfigContent).not.toHaveBeenCalled();
180
183
  expect(updateSyncRules).toHaveBeenCalledTimes(1);
184
+ expect(updateSyncRules).toHaveBeenCalledWith(
185
+ expect.objectContaining({ version_label: 'v6', forceNewReplicationStream: true })
186
+ );
181
187
  expect(response.connections[0].slot_name).toBe('new_slot');
182
188
  });
183
189
 
@@ -1,4 +1,12 @@
1
- import { BasicRouterRequest, Context, JwtPayload, SyncRulesBucketStorage } from '@/index.js';
1
+ import {
2
+ BasicRouterRequest,
3
+ CHECKPOINT_INVALIDATE_ALL,
4
+ Context,
5
+ JwtPayload,
6
+ RequestTracker,
7
+ streamResponse,
8
+ SyncRulesBucketStorage
9
+ } from '@/index.js';
2
10
  import { logger, RouterResponse, ServiceError } from '@powersync/lib-services-framework';
3
11
  import {
4
12
  DEFAULT_HYDRATION_STATE,
@@ -9,7 +17,7 @@ import {
9
17
  import * as sqlite from 'node:sqlite';
10
18
  import { Readable, Writable } from 'stream';
11
19
  import { pipeline } from 'stream/promises';
12
- import { describe, expect, it } from 'vitest';
20
+ import { describe, expect, it, vi } from 'vitest';
13
21
  import winston from 'winston';
14
22
  import { syncStreamed } from '../../../src/routes/endpoints/sync-stream.js';
15
23
  import { DEFAULT_PARAM_LOGGING_FORMAT_OPTIONS, limitParamsForLogging } from '../../../src/util/param-logging.js';
@@ -182,6 +190,119 @@ describe('Stream Route', () => {
182
190
  );
183
191
  });
184
192
  });
193
+
194
+ it('closes after a data fetch error while waiting for a new checkpoint', async () => {
195
+ // After 1,000 operations, the sync stream concurrently calls next() on the
196
+ // checkpoint watcher. This test then fails the next data fetch without
197
+ // producing another checkpoint. Before the fix, awaiting watcher.return()
198
+ // during cleanup queued it behind that pending next(), leaving the response
199
+ // open until an unrelated checkpoint was written.
200
+ const syncRules = SqlSyncRules.fromYaml(
201
+ `
202
+ bucket_definitions:
203
+ global:
204
+ data: []
205
+ `,
206
+ { defaultSchema: 'public' }
207
+ ).config.hydrate(defaultHydrationOptions);
208
+
209
+ let checkpointWatcherAborted = false;
210
+ let dataFetches = 0;
211
+ const storage = {
212
+ watchCheckpointChanges: async function* ({ signal }) {
213
+ yield {
214
+ base: {
215
+ checkpoint: 1n,
216
+ lsn: '1',
217
+ getParameterSets: async () => []
218
+ },
219
+ writeCheckpoint: null,
220
+ update: CHECKPOINT_INVALIDATE_ALL
221
+ };
222
+
223
+ await new Promise<void>((_resolve, reject) => {
224
+ // There is intentionally no next checkpoint: cleanup must abort this
225
+ // pending wait rather than wait for it to complete naturally.
226
+ signal.addEventListener(
227
+ 'abort',
228
+ () => {
229
+ checkpointWatcherAborted = true;
230
+ reject(new Error('Checkpoint watcher aborted'));
231
+ },
232
+ { once: true }
233
+ );
234
+ });
235
+ },
236
+ getChecksums: async (_checkpoint, buckets) =>
237
+ new Map(buckets.map(({ bucket }) => [bucket, { bucket, checksum: 1, count: 1000 }])),
238
+ getBucketDataBatch: async function* () {
239
+ if (dataFetches++ > 0) {
240
+ throw new Error('Simulated data fetch failure');
241
+ }
242
+
243
+ yield {
244
+ chunkData: {
245
+ bucket: 'global[]',
246
+ data: Array.from({ length: 1000 }, () => ({ op: 'PUT' })),
247
+ has_more: true,
248
+ after: '0',
249
+ next_after: '1000'
250
+ },
251
+ targetOp: null
252
+ };
253
+ yield { hasMore: true };
254
+ }
255
+ } as Partial<SyncRulesBucketStorage>;
256
+ const serviceContext = mockServiceContext(storage);
257
+ const controller = new AbortController();
258
+ const checkpointLogger = logger.child({});
259
+ const checkpointLogSpy = vi.spyOn(checkpointLogger, 'info');
260
+
261
+ const response = (async () => {
262
+ for await (const _line of streamResponse({
263
+ syncContext: serviceContext.syncContext,
264
+ bucketStorage: storage as SyncRulesBucketStorage,
265
+ syncRules,
266
+ params: { client_id: 'test-client', raw_data: true },
267
+ token: new JwtPayload({ sub: 'test-user', exp: Date.now() / 1000 + 10_000 }),
268
+ tracker: new RequestTracker(serviceContext.metricsEngine),
269
+ isEncodingAsBson: false,
270
+ signal: controller.signal,
271
+ logger: checkpointLogger
272
+ })) {
273
+ // Consume the response until the simulated data fetch error is propagated.
274
+ }
275
+ })();
276
+
277
+ let timeout: NodeJS.Timeout | undefined;
278
+ const outcome = await Promise.race([
279
+ response.then(
280
+ () => new Error('Sync stream unexpectedly completed'),
281
+ (error) => error
282
+ ),
283
+ new Promise((resolve) => {
284
+ timeout = setTimeout(() => resolve(new Error('Sync stream did not close')), 500);
285
+ })
286
+ ]);
287
+ clearTimeout(timeout);
288
+
289
+ // Capture this before aborting below, which would set it regardless of the stream cleanup.
290
+ const abortedByCleanup = checkpointWatcherAborted;
291
+
292
+ controller.abort();
293
+ await response.catch(() => {});
294
+
295
+ expect(outcome).toBeInstanceOf(Error);
296
+ expect((outcome as Error).message).toContain('Simulated data fetch failure');
297
+ expect(abortedByCleanup).toBe(true);
298
+ expect(checkpointLogSpy).toHaveBeenCalledWith('checkpoint_interrupted: 1', {
299
+ checkpoint: 1n,
300
+ user_id: 'test-user',
301
+ operations_synced: 1000,
302
+ data_synced_bytes: 0,
303
+ ms: expect.any(Object)
304
+ });
305
+ });
185
306
  });
186
307
 
187
308
  export async function drainWithTimeout(readable: Readable, ms = 2_000) {
@@ -43,7 +43,8 @@ describe('SourceTable', () => {
43
43
  test('clone preserves all properties including storeCurrentData', () => {
44
44
  const table = makeTable({
45
45
  replicaIdColumns: [{ name: 'id', type: 'int4' }],
46
- snapshotComplete: false
46
+ snapshotComplete: false,
47
+ eventDefinitionIds: new Set(['event-1'])
47
48
  });
48
49
 
49
50
  table.syncData = false;
@@ -63,6 +64,8 @@ describe('SourceTable', () => {
63
64
  expect(cloned.syncEvent).toBe(false);
64
65
  expect(cloned.storeCurrentData).toBe(false);
65
66
  expect(cloned.snapshotStatus).toEqual(table.snapshotStatus);
67
+ expect(cloned.eventDefinitionIds).toEqual(new Set(['event-1']));
68
+ expect(cloned.eventDefinitionIds).not.toBe(table.eventDefinitionIds);
66
69
  });
67
70
  });
68
71
 
@@ -0,0 +1,148 @@
1
+ import { MetricsEngine } from '@/metrics/MetricsEngine.js';
2
+ import {
3
+ MetricMetadata,
4
+ MetricsFactory,
5
+ ObservableGauge,
6
+ ObservableGaugeObservation
7
+ } from '@/metrics/metrics-interfaces.js';
8
+ import { BucketStorageFactory, StorageMetrics, StorageSyncConfigMetrics } from '@/storage/BucketStorageFactory.js';
9
+ import { createCoreStorageMetrics, initializeCoreStorageMetrics } from '@/storage/storage-metrics.js';
10
+ import { StorageMetric } from '@powersync/service-types';
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
12
+
13
+ type ValueProvider = () => Promise<number | ObservableGaugeObservation[] | undefined>;
14
+
15
+ function testEngine() {
16
+ const providers = new Map<string, ValueProvider>();
17
+ const factory: MetricsFactory = {
18
+ createCounter: () => ({ add: () => {} }),
19
+ createUpDownCounter: () => ({ add: () => {} }),
20
+ createObservableGauge: (metadata: MetricMetadata): ObservableGauge => ({
21
+ setValueProvider: (valueProvider) => providers.set(metadata.name, valueProvider)
22
+ })
23
+ };
24
+ const engine = new MetricsEngine({ factory, disable_telemetry_sharing: true });
25
+ createCoreStorageMetrics(engine);
26
+ return { engine, providers };
27
+ }
28
+
29
+ function syncConfigMetrics(id: string, state: string, bytes: number, versionLabel?: string): StorageSyncConfigMetrics {
30
+ return {
31
+ sync_config_id: id,
32
+ sync_config_state: state,
33
+ version_label: versionLabel,
34
+ attributed_bucket_data_bytes: bytes,
35
+ attributed_parameter_indexes_bytes: bytes,
36
+ attributed_source_records_bytes: bytes,
37
+ attributed_object_storage_bytes: bytes
38
+ };
39
+ }
40
+
41
+ function storageMetrics(sync_config_metrics: StorageSyncConfigMetrics[]): StorageMetrics {
42
+ return {
43
+ operations_size_bytes: 0,
44
+ parameters_size_bytes: 0,
45
+ replication_size_bytes: 0,
46
+ object_storage_size_bytes: 0,
47
+ sync_config_metrics
48
+ };
49
+ }
50
+
51
+ describe('storage metrics', () => {
52
+ beforeEach(() => {
53
+ vi.useFakeTimers();
54
+ vi.setSystemTime(0);
55
+ });
56
+
57
+ afterEach(() => {
58
+ vi.useRealTimers();
59
+ });
60
+
61
+ it('reports retired sync configs as zero, then stops reporting them', async () => {
62
+ let current = storageMetrics([syncConfigMetrics('config-a', 'ACTIVE', 100)]);
63
+ const storage = {
64
+ getStorageMetrics: async () => current
65
+ } as unknown as BucketStorageFactory;
66
+
67
+ const { engine, providers } = testEngine();
68
+ initializeCoreStorageMetrics(engine, storage);
69
+ const provider = providers.get(StorageMetric.ATTRIBUTED_BUCKET_DATA_BYTES)!;
70
+
71
+ expect(await provider()).toEqual([
72
+ { value: 100, attributes: { sync_config_id: 'config-a', sync_config_state: 'ACTIVE' } }
73
+ ]);
74
+
75
+ // The sync config is pruned. Its series must go to zero rather than keep its last value,
76
+ // since the SDK carries the last reported value forward under cumulative temporality.
77
+ current = storageMetrics([syncConfigMetrics('config-b', 'ACTIVE', 50)]);
78
+ vi.setSystemTime(61_000);
79
+
80
+ expect(await provider()).toEqual([
81
+ { value: 50, attributes: { sync_config_id: 'config-b', sync_config_state: 'ACTIVE' } },
82
+ { value: 0, attributes: { sync_config_id: 'config-a', sync_config_state: 'ACTIVE' } }
83
+ ]);
84
+
85
+ // Once every reader has had a chance to observe the zero, stop reporting it entirely.
86
+ vi.setSystemTime(61_000 + 16 * 60_000);
87
+
88
+ expect(await provider()).toEqual([
89
+ { value: 50, attributes: { sync_config_id: 'config-b', sync_config_state: 'ACTIVE' } }
90
+ ]);
91
+ });
92
+
93
+ it('treats a state transition as a retired attribute set', async () => {
94
+ let current = storageMetrics([syncConfigMetrics('config-a', 'PROCESSING', 100, 'v1')]);
95
+ const storage = {
96
+ getStorageMetrics: async () => current
97
+ } as unknown as BucketStorageFactory;
98
+
99
+ const { engine, providers } = testEngine();
100
+ initializeCoreStorageMetrics(engine, storage);
101
+ const provider = providers.get(StorageMetric.ATTRIBUTED_SOURCE_RECORDS_BYTES)!;
102
+
103
+ await provider();
104
+
105
+ current = storageMetrics([syncConfigMetrics('config-a', 'ACTIVE', 120, 'v1')]);
106
+ vi.setSystemTime(61_000);
107
+
108
+ expect(await provider()).toEqual([
109
+ {
110
+ value: 120,
111
+ attributes: { sync_config_id: 'config-a', sync_config_state: 'ACTIVE', version_label: 'v1' }
112
+ },
113
+ {
114
+ value: 0,
115
+ attributes: { sync_config_id: 'config-a', sync_config_state: 'PROCESSING', version_label: 'v1' }
116
+ }
117
+ ]);
118
+ });
119
+
120
+ it('does not report anything as retired when reading the metrics fails', async () => {
121
+ let fail = false;
122
+ const storage = {
123
+ getStorageMetrics: async () => {
124
+ if (fail) {
125
+ throw new Error('storage unavailable');
126
+ }
127
+ return storageMetrics([syncConfigMetrics('config-a', 'ACTIVE', 100)]);
128
+ }
129
+ } as unknown as BucketStorageFactory;
130
+
131
+ const { engine, providers } = testEngine();
132
+ initializeCoreStorageMetrics(engine, storage);
133
+ const provider = providers.get(StorageMetric.ATTRIBUTED_OBJECT_STORAGE_BYTES)!;
134
+
135
+ await provider();
136
+
137
+ fail = true;
138
+ vi.setSystemTime(61_000);
139
+ expect(await provider()).toBeUndefined();
140
+
141
+ // The failure must not be interpreted as the sync config having been pruned.
142
+ fail = false;
143
+ vi.setSystemTime(122_000);
144
+ expect(await provider()).toEqual([
145
+ { value: 100, attributes: { sync_config_id: 'config-a', sync_config_state: 'ACTIVE' } }
146
+ ]);
147
+ });
148
+ });
@@ -15,6 +15,25 @@ describe('isAbortError', () => {
15
15
  });
16
16
 
17
17
  describe('acquireSemaphoreAbortable', () => {
18
+ test('waits for an available slot without a signal', async () => {
19
+ const semaphore = new Semaphore(1);
20
+ const [, release] = await semaphore.acquire();
21
+ const resolved = vi.fn();
22
+ const waiting = acquireSemaphoreAbortable(semaphore).then((result) => {
23
+ resolved();
24
+ return result;
25
+ });
26
+ await Promise.resolve();
27
+ expect(resolved).not.toHaveBeenCalled();
28
+
29
+ release();
30
+ const acquired = await waiting;
31
+ expect(acquired).not.toBe('aborted');
32
+ expect(semaphore.getValue()).toBe(0);
33
+ (acquired as [number, SemaphoreInterface.Releaser])[1]();
34
+ expect(semaphore.getValue()).toBe(1);
35
+ });
36
+
18
37
  test('can acquire', async () => {
19
38
  const semaphore = new Semaphore(1);
20
39
  const controller = new AbortController();
@@ -43,4 +62,19 @@ describe('acquireSemaphoreAbortable', () => {
43
62
  // Releasing the semaphore should not invoke resolve again
44
63
  release();
45
64
  });
65
+
66
+ test('does not wait when the signal is already aborted', async () => {
67
+ const semaphore = new Semaphore(1);
68
+ const controller = new AbortController();
69
+
70
+ // Hold the only slot, so acquiring would block indefinitely.
71
+ const result = await acquireSemaphoreAbortable(semaphore, controller.signal);
72
+ expect(result).not.toBe('aborted');
73
+ const [, release] = result as [number, SemaphoreInterface.Releaser];
74
+
75
+ controller.abort();
76
+ expect(await acquireSemaphoreAbortable(semaphore, controller.signal)).toBe('aborted');
77
+
78
+ release();
79
+ });
46
80
  });
@@ -0,0 +1,23 @@
1
+ import { formatBytes } from '@/index.js';
2
+ import { describe, expect, test } from 'vitest';
3
+
4
+ describe('formatBytes', () => {
5
+ test('formats bytes', () => {
6
+ expect(formatBytes(500)).toBe('500B');
7
+ });
8
+
9
+ test('formats kilobytes', () => {
10
+ expect(formatBytes(1024)).toBe('1.0KB');
11
+ expect(formatBytes(1536)).toBe('1.5KB');
12
+ });
13
+
14
+ test('formats megabytes', () => {
15
+ expect(formatBytes(1024 * 1024)).toBe('1.0MB');
16
+ expect(formatBytes(1.5 * 1024 * 1024)).toBe('1.5MB');
17
+ });
18
+
19
+ test('formats gigabytes', () => {
20
+ expect(formatBytes(1024 * 1024 * 1024)).toBe('1.0GB');
21
+ expect(formatBytes(10.5 * 1024 * 1024 * 1024)).toBe('10.5GB');
22
+ });
23
+ });