@powersync/service-core 1.26.0 → 1.27.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 (57) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/entry/commands/compact-action.js +1 -1
  3. package/dist/entry/commands/compact-action.js.map +1 -1
  4. package/dist/entry/commands/config-command.js +1 -1
  5. package/dist/entry/commands/config-command.js.map +1 -1
  6. package/dist/migrations/PowerSyncMigrationManager.js +2 -1
  7. package/dist/migrations/PowerSyncMigrationManager.js.map +1 -1
  8. package/dist/replication/AbstractReplicator.js +2 -2
  9. package/dist/replication/AbstractReplicator.js.map +1 -1
  10. package/dist/replication/ReplicationModule.d.ts +0 -7
  11. package/dist/replication/ReplicationModule.js +0 -1
  12. package/dist/replication/ReplicationModule.js.map +1 -1
  13. package/dist/routes/endpoints/admin.js +1 -1
  14. package/dist/routes/endpoints/admin.js.map +1 -1
  15. package/dist/routes/endpoints/socket-route.js +7 -5
  16. package/dist/routes/endpoints/socket-route.js.map +1 -1
  17. package/dist/storage/BucketStorageFactory.d.ts +9 -2
  18. package/dist/storage/BucketStorageFactory.js +3 -0
  19. package/dist/storage/BucketStorageFactory.js.map +1 -1
  20. package/dist/storage/StorageVersionConfig.d.ts +0 -4
  21. package/dist/storage/StorageVersionConfig.js +0 -4
  22. package/dist/storage/StorageVersionConfig.js.map +1 -1
  23. package/dist/storage/SyncRulesBucketStorage.d.ts +2 -0
  24. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  25. package/dist/storage/implementation/IncrementalReprocessingCompatibility.js +4 -4
  26. package/dist/sync/BucketChecksumState.d.ts +1 -1
  27. package/dist/sync/BucketChecksumState.js +5 -4
  28. package/dist/sync/BucketChecksumState.js.map +1 -1
  29. package/dist/sync/util.d.ts +1 -1
  30. package/dist/sync/util.js +3 -0
  31. package/dist/sync/util.js.map +1 -1
  32. package/dist/util/param-logging.d.ts +5 -2
  33. package/dist/util/param-logging.js +17 -9
  34. package/dist/util/param-logging.js.map +1 -1
  35. package/dist/util/protocol-types.d.ts +7 -7
  36. package/dist/util/protocol-types.js +3 -3
  37. package/package.json +5 -5
  38. package/src/entry/commands/compact-action.ts +1 -1
  39. package/src/entry/commands/config-command.ts +1 -1
  40. package/src/migrations/PowerSyncMigrationManager.ts +2 -1
  41. package/src/replication/AbstractReplicator.ts +2 -2
  42. package/src/replication/ReplicationModule.ts +0 -10
  43. package/src/routes/endpoints/admin.ts +1 -1
  44. package/src/routes/endpoints/socket-route.ts +9 -6
  45. package/src/storage/BucketStorageFactory.ts +11 -2
  46. package/src/storage/StorageVersionConfig.ts +0 -5
  47. package/src/storage/SyncRulesBucketStorage.ts +2 -0
  48. package/src/storage/implementation/IncrementalReprocessingCompatibility.ts +4 -4
  49. package/src/sync/BucketChecksumState.ts +5 -4
  50. package/src/sync/util.ts +4 -1
  51. package/src/util/param-logging.ts +21 -14
  52. package/src/util/protocol-types.ts +5 -5
  53. package/test/src/broadcast_iterable.test.ts +26 -8
  54. package/test/src/sync/BucketChecksumState.test.ts +195 -6
  55. package/test/src/sync/util.test.ts +19 -0
  56. package/test/src/util/param-logging.test.ts +59 -0
  57. package/tsconfig.tsbuildinfo +1 -1
@@ -18,8 +18,9 @@ export const DEFAULT_PARAM_LOGGING_FORMAT_OPTIONS: ParamLoggingFormatOptions = {
18
18
  * Formats potentially arbitrary parameters for logging.
19
19
  * This limits the number of keys and strings to a maximum length.
20
20
  * A warning key-value is added if the number of keys exceeds the maximum.
21
- * String values exceeding the maximum length are truncated.
21
+ * Keys and string values exceeding the maximum length are truncated.
22
22
  * Non-String values are stringified, the maximum length is then applied.
23
+ * Values that cannot be serialized are replaced with a placeholder.
23
24
  * @param params - The parameters to format.
24
25
  * @param options - The options to use.
25
26
  * @default DEFAULT_PARAM_LOGGING_FORMAT_OPTIONS
@@ -41,20 +42,26 @@ export function limitParamsForLogging(
41
42
  return value;
42
43
  }
43
44
 
44
- return Object.fromEntries(
45
- Object.entries(params).map(([key, value], index) => {
46
- if (index == maxKeyCount) {
47
- return ['⚠️', 'Additional parameters omitted'];
48
- }
45
+ function formatValue(value: unknown): string {
46
+ if (typeof value == 'string') {
47
+ return trimString(value);
48
+ }
49
+ try {
50
+ return trimString(JSON.stringify(value) ?? '[undefined]');
51
+ } catch {
52
+ return trimString('[unserializable]');
53
+ }
54
+ }
49
55
 
50
- if (index > maxKeyCount) {
51
- return [];
52
- }
56
+ return Object.fromEntries(
57
+ Object.entries(params)
58
+ .slice(0, maxKeyCount + 1)
59
+ .map(([key, value], index) => {
60
+ if (index == maxKeyCount) {
61
+ return ['⚠️', 'Additional parameters omitted'];
62
+ }
53
63
 
54
- if (typeof value == 'string') {
55
- return [key, trimString(value)];
56
- }
57
- return [key, trimString(JSON.stringify(value))];
58
- })
64
+ return [trimString(key), formatValue(value)];
65
+ })
59
66
  );
60
67
  }
@@ -19,7 +19,7 @@ export type BucketRequest = t.Decoded<typeof BucketRequest>;
19
19
  */
20
20
  export const RequestedStreamSubscription = t.object({
21
21
  /**
22
- * The defined name of the stream as it appears in sync stream definitions.
22
+ * The defined name of the stream as it appears in Sync Stream definitions.
23
23
  */
24
24
  stream: t.string,
25
25
  /**
@@ -29,7 +29,7 @@ export const RequestedStreamSubscription = t.object({
29
29
  /**
30
30
  * Set when the client wishes to re-assign a different priority to this stream.
31
31
  *
32
- * Streams and sync rules can also assign a default priority, but clients are allowed to override those. This can be
32
+ * Sync Streams and legacy bucket definitions can also assign a default priority, but clients are allowed to override those. This can be
33
33
  * useful when the priority for partial syncs depends on e.g. the current page opened in a client.
34
34
  */
35
35
  override_priority: orNull(t.number)
@@ -49,7 +49,7 @@ export const StreamSubscriptionRequest = t.object({
49
49
  include_defaults: t.boolean.optional(),
50
50
 
51
51
  /**
52
- * An array of sync streams the client has opened explicitly.
52
+ * An array of Sync Streams the client has opened explicitly.
53
53
  */
54
54
  subscriptions: t.array(RequestedStreamSubscription)
55
55
  });
@@ -253,8 +253,8 @@ export interface BucketChecksum {
253
253
  /**
254
254
  * The reason a particular bucket is included in a checkpoint.
255
255
  *
256
- * This information allows clients to associate individual buckets with sync streams they're subscribed to. Having that
257
- * association is useful because it enables clients to track progress for individual sync streams.
256
+ * This information allows clients to associate individual buckets with Sync Streams they're subscribed to. Having that
257
+ * association is useful because it enables clients to track progress for individual Sync Streams.
258
258
  */
259
259
  export type BucketSubscriptionReason = BucketDerivedFromDefaultStream | BucketDerivedFromExplicitSubscription;
260
260
 
@@ -4,10 +4,16 @@ import { delayEach } from 'ix/asynciterable/operators/delayeach.js';
4
4
  import { take } from 'ix/asynciterable/operators/take.js';
5
5
  import { wrapWithAbort } from 'ix/asynciterable/operators/withabort.js';
6
6
  import { toArray } from 'ix/asynciterable/toarray.js';
7
- import * as timers from 'timers/promises';
8
- import { describe, expect, it } from 'vitest';
7
+ import { afterEach, describe, expect, it, vi } from 'vitest';
8
+
9
+ // The global setTimeout, not the one from timers/promises: vi.useFakeTimers() only replaces the global timers
10
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
9
11
 
10
12
  describe('BroadcastIterable', () => {
13
+ afterEach(() => {
14
+ vi.useRealTimers();
15
+ });
16
+
11
17
  it('should iterate', async () => {
12
18
  const range = AsyncIterableX.from([1, 2, 3]);
13
19
  const broadcast = new BroadcastIterable(() => range);
@@ -47,6 +53,7 @@ describe('BroadcastIterable', () => {
47
53
  });
48
54
 
49
55
  it('should handle indefinite sources', async () => {
56
+ vi.useFakeTimers();
50
57
  const source: IterableSource<number> = (signal) => {
51
58
  return wrapWithAbort(interval(1), signal);
52
59
  };
@@ -55,7 +62,9 @@ describe('BroadcastIterable', () => {
55
62
 
56
63
  const delayed = delayEach(10)(broadcast);
57
64
  expect(broadcast.active).toBe(false);
58
- const results = await toArray(take(5)(delayed));
65
+ const resultsPromise = toArray(take(5)(delayed));
66
+ await vi.advanceTimersByTimeAsync(100);
67
+ const results = await resultsPromise;
59
68
 
60
69
  expect(results.length).toEqual(5);
61
70
  expect(results[0]).toEqual(0);
@@ -66,6 +75,7 @@ describe('BroadcastIterable', () => {
66
75
  });
67
76
 
68
77
  it('should handle multiple subscribers', async () => {
78
+ vi.useFakeTimers();
69
79
  let sourceIndex = 0;
70
80
  const source = async function* (signal: AbortSignal) {
71
81
  // Test value out by 1000 means it may have used the wrong iteration of the source
@@ -75,7 +85,7 @@ describe('BroadcastIterable', () => {
75
85
  });
76
86
  for (let i = 0; !signal.aborted; i++) {
77
87
  yield base + i;
78
- await Promise.race([abortedPromise, timers.setTimeout(1)]);
88
+ await Promise.race([abortedPromise, sleep(1)]);
79
89
  }
80
90
  // Test value out by 100 means this wasn't reached
81
91
  sourceIndex += 100;
@@ -88,6 +98,7 @@ describe('BroadcastIterable', () => {
88
98
  expect(broadcast.active).toBe(false);
89
99
  const results1Promise = toArray(take(5)(delayed1));
90
100
  const results2Promise = toArray(take(5)(delayed2));
101
+ await vi.advanceTimersByTimeAsync(100);
91
102
  const [results1, results2] = [await results1Promise, await results2Promise];
92
103
 
93
104
  expect(broadcast.active).toBe(false);
@@ -104,7 +115,9 @@ describe('BroadcastIterable', () => {
104
115
 
105
116
  // This starts a new source
106
117
  const delayed3 = delayEach(10)(broadcast);
107
- const results3 = await toArray(take(5)(delayed3));
118
+ const results3Promise = toArray(take(5)(delayed3));
119
+ await vi.advanceTimersByTimeAsync(100);
120
+ const results3 = await results3Promise;
108
121
  expect(results3.length).toEqual(5);
109
122
  expect(results3[0]).toEqual(2100);
110
123
  expect(results3[4]).toBeGreaterThan(2110);
@@ -112,6 +125,7 @@ describe('BroadcastIterable', () => {
112
125
  });
113
126
 
114
127
  it('should handle errors on multiple subscribers', async () => {
128
+ vi.useFakeTimers();
115
129
  let sourceIndex = 0;
116
130
  const source = async function* (signal: AbortSignal) {
117
131
  // Test value out by 1000 means it may have used the wrong iteration of the source
@@ -124,7 +138,7 @@ describe('BroadcastIterable', () => {
124
138
  throw new Error('simulated failure');
125
139
  }
126
140
  yield base + i;
127
- await Promise.race([abortedPromise, timers.setTimeout(1)]);
141
+ await Promise.race([abortedPromise, sleep(1)]);
128
142
  }
129
143
  // Test value out by 100 means this wasn't reached
130
144
  sourceIndex += 100;
@@ -138,7 +152,9 @@ describe('BroadcastIterable', () => {
138
152
  const results1Promise = toArray(take(5)(delayed1)) as Promise<number[]>;
139
153
  const results2Promise = toArray(take(5)(delayed2)) as Promise<number[]>;
140
154
 
141
- const [r1, r2] = await Promise.allSettled([results1Promise, results2Promise]);
155
+ const settled = Promise.allSettled([results1Promise, results2Promise]);
156
+ await vi.advanceTimersByTimeAsync(100);
157
+ const [r1, r2] = await settled;
142
158
 
143
159
  expect(r1).toEqual({ status: 'rejected', reason: new Error('simulated failure') });
144
160
  expect(r2).toEqual({ status: 'rejected', reason: new Error('simulated failure') });
@@ -147,7 +163,9 @@ describe('BroadcastIterable', () => {
147
163
 
148
164
  // This starts a new source
149
165
  const delayed3 = delayEach(10)(broadcast);
150
- const results3 = await toArray(take(5)(delayed3));
166
+ const results3Promise = toArray(take(5)(delayed3));
167
+ await vi.advanceTimersByTimeAsync(100);
168
+ const results3 = await results3Promise;
151
169
  expect(results3.length).toEqual(5);
152
170
  expect(results3[0]).toEqual(2000);
153
171
  expect(results3[4]).toBeGreaterThan(2010);
@@ -719,6 +719,195 @@ config:
719
719
  storage.updateTestChecksum({ bucket: '1#stream|0["b"]', checksum: 1, count: 1 });
720
720
  });
721
721
 
722
+ // Auto-subscription includes profiles in both cases. The explicit subscription adds another
723
+ // inclusion reason and overrides priority 3 with priority 1; it does not enable the stream.
724
+ test.each([false, true])(
725
+ 'fetches overlapping static and dynamic buckets only once (custom subscription: %s)',
726
+ async (subscribe) => {
727
+ // Alice's profile is selected directly through auth.user_id() and also
728
+ // through her membership in her own group. Both branches resolve to the same bucket.
729
+ const state = checksumState(
730
+ /* yaml */ `
731
+ # Select the same profile through static and dynamic parameters.
732
+ config:
733
+ edition: 3
734
+ streams:
735
+ profiles:
736
+ auto_subscribe: true
737
+ query: |
738
+ SELECT id, name FROM profiles
739
+ WHERE id = auth.user_id()
740
+ OR id IN (SELECT member_id FROM memberships WHERE owner_id = auth.user_id())
741
+ `,
742
+ {
743
+ tokenPayload: new JwtPayload({ sub: 'alice' }),
744
+ syncRequest: {
745
+ streams: {
746
+ subscriptions: subscribe ? [{ stream: 'profiles', parameters: null, override_priority: 1 }] : []
747
+ }
748
+ }
749
+ }
750
+ );
751
+ const bucket = '1#profiles|0["alice"]';
752
+ storage.updateTestChecksum({ bucket, checksum: 1, count: 1 });
753
+
754
+ const line = (await state.buildNextCheckpointLine({
755
+ // Supply the membership lookup result without a database. '0' is the generated
756
+ // output column for member_id in the compiled parameter query.
757
+ base: storage.makeCheckpoint(1n, (lookups) => [{ lookup: lookups[0], rows: [{ '0': 'alice' }] }]),
758
+ writeCheckpoint: null,
759
+ update: CHECKPOINT_INVALIDATE_ALL
760
+ }))!;
761
+ line.advance();
762
+
763
+ // Check the request arrays directly: converting them to a Map would hide duplicates.
764
+ // Postgres fetches operations once per request, so duplicates here duplicate the data.
765
+ expect(line.bucketsToFetch.map((description) => description.bucket)).toEqual([bucket]);
766
+ expect(line.getFilteredBucketPositions().map((request) => request.bucket)).toEqual([bucket]);
767
+ // Merging must retain the default and any explicit subscription, using the lowest
768
+ // numeric priority (the highest sync priority) among them.
769
+ expect(line.checkpointLine).toMatchObject({
770
+ checkpoint: {
771
+ buckets: [
772
+ {
773
+ bucket,
774
+ checksum: 1,
775
+ count: 1,
776
+ priority: subscribe ? 1 : 3,
777
+ subscriptions: expect.arrayContaining(subscribe ? [{ default: 0 }, { sub: 0 }] : [{ default: 0 }])
778
+ }
779
+ ]
780
+ }
781
+ });
782
+
783
+ // Only bucket data changes at this checkpoint, so reuse the cached membership result.
784
+ // The first download is still pending; its next fetch must also contain only one request.
785
+ // Incremental requests are already deduplicated by a Set, so also check the description:
786
+ // it must retain the inclusion reasons from both the static and cached dynamic matches.
787
+ storage.updateTestChecksum({ bucket, checksum: 2, count: 2 });
788
+ const cachedLine = (await state.buildNextCheckpointLine({
789
+ base: storage.makeCheckpoint(2n),
790
+ writeCheckpoint: null,
791
+ update: {
792
+ invalidateDataBuckets: false,
793
+ invalidateParameterBuckets: false,
794
+ updatedDataBuckets: new Set([bucket]),
795
+ updatedParameterLookups: new Set()
796
+ }
797
+ }))!;
798
+ cachedLine.advance();
799
+ expect(cachedLine.getFilteredBucketPositions().map((request) => request.bucket)).toEqual([bucket]);
800
+ expect(cachedLine.bucketsToFetch).toMatchObject([
801
+ {
802
+ bucket,
803
+ priority: subscribe ? 1 : 3,
804
+ inclusion_reasons: expect.arrayContaining(subscribe ? ['default', { subscription: 0 }] : ['default'])
805
+ }
806
+ ]);
807
+ expect(cachedLine.checkpointLine).toMatchObject({
808
+ checkpoint_diff: {
809
+ updated_buckets: [
810
+ {
811
+ bucket,
812
+ checksum: 2,
813
+ count: 2,
814
+ priority: subscribe ? 1 : 3,
815
+ subscriptions: expect.arrayContaining(subscribe ? [{ default: 0 }, { sub: 0 }] : [{ default: 0 }])
816
+ }
817
+ ]
818
+ }
819
+ });
820
+
821
+ // Simulate Alice leaving the group by returning no membership rows. The direct auth
822
+ // match must keep her bucket. Change its checksum so a new checkpoint line is emitted.
823
+ storage.updateTestChecksum({ bucket, checksum: 3, count: 3 });
824
+ const staticLine = (await state.buildNextCheckpointLine({
825
+ base: storage.makeCheckpoint(3n, (lookups) => lookups.map((lookup) => ({ lookup, rows: [] }))),
826
+ writeCheckpoint: null,
827
+ update: CHECKPOINT_INVALIDATE_ALL
828
+ }))!;
829
+ staticLine.advance();
830
+ expect(staticLine.getFilteredBucketPositions().map((request) => request.bucket)).toEqual([bucket]);
831
+ }
832
+ );
833
+
834
+ test('preserves static subscription metadata when a dynamic match overlaps', async () => {
835
+ const state = checksumState(
836
+ /* yaml */ `
837
+ # The explicit subscription contributes priority through its static match.
838
+ config:
839
+ edition: 3
840
+ streams:
841
+ profiles:
842
+ auto_subscribe: true
843
+ query: |
844
+ SELECT id, name FROM profiles
845
+ WHERE id = auth.user_id()
846
+ OR id IN (
847
+ SELECT member_id FROM memberships
848
+ WHERE owner_id = ifnull(subscription.parameter('owner_id'), auth.user_id())
849
+ )
850
+ `,
851
+ {
852
+ tokenPayload: new JwtPayload({ sub: 'alice' }),
853
+ syncRequest: {
854
+ streams: {
855
+ subscriptions: [{ stream: 'profiles', parameters: { owner_id: 'bob' }, override_priority: 1 }]
856
+ }
857
+ }
858
+ }
859
+ );
860
+ const bucket = '1#profiles|0["alice"]';
861
+
862
+ // Both subscriptions select Alice statically, but only the default subscription finds
863
+ // her through memberships. Keeping only the dynamic description would lose sub: 0 and
864
+ // its priority override. Check both the initial and cached incremental checkpoints.
865
+ for (const checkpoint of [1n, 2n]) {
866
+ storage.updateTestChecksum({ bucket, checksum: Number(checkpoint), count: Number(checkpoint) });
867
+ const line = (await state.buildNextCheckpointLine({
868
+ base: storage.makeCheckpoint(
869
+ checkpoint,
870
+ checkpoint === 1n
871
+ ? (lookups) =>
872
+ lookups.map((lookup) => ({
873
+ lookup,
874
+ rows: lookup.values.includes('alice') ? [{ '0': 'alice' }] : []
875
+ }))
876
+ : undefined
877
+ ),
878
+ writeCheckpoint: null,
879
+ update:
880
+ checkpoint === 1n
881
+ ? CHECKPOINT_INVALIDATE_ALL
882
+ : {
883
+ invalidateDataBuckets: false,
884
+ invalidateParameterBuckets: false,
885
+ updatedDataBuckets: new Set([bucket]),
886
+ updatedParameterLookups: new Set()
887
+ }
888
+ }))!;
889
+ line.advance();
890
+ expect(line.bucketsToFetch).toMatchObject([
891
+ {
892
+ bucket,
893
+ priority: 1,
894
+ inclusion_reasons: expect.arrayContaining(['default', { subscription: 0 }])
895
+ }
896
+ ]);
897
+ const descriptions =
898
+ 'checkpoint' in line.checkpointLine
899
+ ? line.checkpointLine.checkpoint.buckets
900
+ : line.checkpointLine.checkpoint_diff.updated_buckets;
901
+ expect(descriptions).toMatchObject([
902
+ {
903
+ bucket,
904
+ priority: 1,
905
+ subscriptions: expect.arrayContaining([{ default: 0 }, { sub: 0 }])
906
+ }
907
+ ]);
908
+ }
909
+ });
910
+
722
911
  test('includes defaults', async () => {
723
912
  const state = checksumState(true);
724
913
  const line = await state.buildNextCheckpointLine({
@@ -1183,7 +1372,7 @@ streams:
1183
1372
  ).rejects.toThrow('Too many buckets: 60 (limit of 50');
1184
1373
 
1185
1374
  // Verify error log includes breakdown
1186
- expect(errorMessages[0]).toContain('(limit of 50)\nBuckets by sync stream:');
1375
+ expect(errorMessages[0]).toContain('(limit of 50)\nBuckets by Sync Stream:');
1187
1376
  expect(errorMessages[0]).toContain('projects: 30');
1188
1377
  expect(errorMessages[0]).toContain('tasks: 20');
1189
1378
  expect(errorMessages[0]).toContain('comments: 10');
@@ -1242,8 +1431,8 @@ bucket_definitions:
1242
1431
  expect(errorMessages[0]).toContain('projects: 60');
1243
1432
  });
1244
1433
 
1245
- test('limits bucket breakdown to top 100 sync streams', async () => {
1246
- // Create 105 sync streams with dynamic parameters.
1434
+ test('limits bucket breakdown to top 100 Sync Streams', async () => {
1435
+ // Create 105 Sync Streams with dynamic parameters.
1247
1436
  let yamlDefinitions = `
1248
1437
  config:
1249
1438
  edition: 3
@@ -1301,10 +1490,10 @@ streams:
1301
1490
 
1302
1491
  // Verify only the first 100 are shown.
1303
1492
  const errorMessage = errorMessages[0];
1304
- expect(errorMessage).toContain('Buckets by sync stream:');
1305
- expect(errorMessage).toContain('... and 5 more buckets from 5 sync streams');
1493
+ expect(errorMessage).toContain('Buckets by Sync Stream:');
1494
+ expect(errorMessage).toContain('... and 5 more buckets from 5 Sync Streams');
1306
1495
 
1307
- // Count how many sync streams are listed (should be exactly 100).
1496
+ // Count how many Sync Streams are listed (should be exactly 100).
1308
1497
  const defMatches = errorMessage.match(/def\d+:/g);
1309
1498
  expect(defMatches?.length).toBe(100);
1310
1499
  });
@@ -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();
@@ -0,0 +1,59 @@
1
+ import { limitParamsForLogging } from '@/util/param-logging.js';
2
+ import { StreamingSyncRequest } from '@/util/protocol-types.js';
3
+ import { schema } from '@powersync/lib-services-framework';
4
+ import { deserialize } from 'bson';
5
+ import { describe, expect, test } from 'vitest';
6
+
7
+ describe('parameter logging', () => {
8
+ test('handles BSON Undefined accepted by the sync request validator', () => {
9
+ // Raw BSON is required: the serializer normally omits undefined values.
10
+ // This encodes { parameters: { x: undefined } } using BSON type 0x06.
11
+ const request = deserialize(Buffer.from('1900000003706172616d657465727300080000000678000000', 'hex'));
12
+ const validator = schema.createTsCodecValidator(StreamingSyncRequest, { allowAdditional: true });
13
+
14
+ expect(validator.validate(request).valid).toBe(true);
15
+ expect(Object.hasOwn(request.parameters, 'x')).toBe(true);
16
+ expect(request.parameters.x).toBeUndefined();
17
+ expect(limitParamsForLogging(request.parameters)).toEqual({ x: '[undefined]' });
18
+ });
19
+
20
+ test('handles values without a JSON representation and serialization errors', () => {
21
+ const circular: Record<string, unknown> = {};
22
+ circular.self = circular;
23
+
24
+ expect(limitParamsForLogging({ missing: undefined, fn: () => {}, symbol: Symbol(), bigint: 1n, circular })).toEqual(
25
+ {
26
+ missing: '[undefined]',
27
+ fn: '[undefined]',
28
+ symbol: '[undefined]',
29
+ bigint: '[unserializable]',
30
+ circular: '[unserializable]'
31
+ }
32
+ );
33
+ });
34
+
35
+ test('bounds keys and values, including JSON-encoded values', () => {
36
+ const params = { ['k'.repeat(100_000)]: 'v'.repeat(1000), nested: { text: 'v'.repeat(1000) }, null: null };
37
+ const result = limitParamsForLogging(params);
38
+
39
+ expect(result['k'.repeat(97) + '...']).toBe('v'.repeat(97) + '...');
40
+ expect(result.nested).toHaveLength(100);
41
+ expect(result.null).toBe('null');
42
+ expect(Object.keys(params)[0]).toHaveLength(100_000);
43
+ });
44
+
45
+ test('omits excess entries without serializing their values', () => {
46
+ const params = Object.fromEntries(Array.from({ length: 20 }, (_, i) => ['key' + i, 'value']));
47
+ const result = limitParamsForLogging({
48
+ ...params,
49
+ omitted: {
50
+ toJSON: () => {
51
+ throw new Error('must not serialize');
52
+ }
53
+ },
54
+ alsoOmitted: 'value'
55
+ });
56
+
57
+ expect(result).toEqual({ ...params, '⚠️': 'Additional parameters omitted' });
58
+ });
59
+ });