@powersync/service-core 1.26.1 → 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.
- package/CHANGELOG.md +24 -0
- package/dist/entry/commands/compact-action.js +1 -1
- package/dist/entry/commands/compact-action.js.map +1 -1
- package/dist/entry/commands/config-command.js +1 -1
- package/dist/entry/commands/config-command.js.map +1 -1
- package/dist/migrations/PowerSyncMigrationManager.js +2 -1
- package/dist/migrations/PowerSyncMigrationManager.js.map +1 -1
- package/dist/replication/AbstractReplicator.js +2 -2
- package/dist/replication/AbstractReplicator.js.map +1 -1
- package/dist/replication/ReplicationModule.d.ts +0 -7
- package/dist/replication/ReplicationModule.js +0 -1
- package/dist/replication/ReplicationModule.js.map +1 -1
- package/dist/routes/endpoints/admin.js +1 -1
- package/dist/routes/endpoints/admin.js.map +1 -1
- package/dist/routes/endpoints/socket-route.js +7 -5
- package/dist/routes/endpoints/socket-route.js.map +1 -1
- package/dist/storage/BucketStorageFactory.d.ts +9 -2
- package/dist/storage/BucketStorageFactory.js +3 -0
- package/dist/storage/BucketStorageFactory.js.map +1 -1
- package/dist/storage/StorageVersionConfig.d.ts +0 -4
- package/dist/storage/StorageVersionConfig.js +0 -4
- package/dist/storage/StorageVersionConfig.js.map +1 -1
- package/dist/storage/SyncRulesBucketStorage.d.ts +2 -0
- package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
- package/dist/storage/implementation/IncrementalReprocessingCompatibility.js +4 -4
- package/dist/sync/BucketChecksumState.d.ts +1 -1
- package/dist/sync/BucketChecksumState.js +5 -4
- package/dist/sync/BucketChecksumState.js.map +1 -1
- package/dist/util/param-logging.d.ts +5 -2
- package/dist/util/param-logging.js +17 -9
- package/dist/util/param-logging.js.map +1 -1
- package/dist/util/protocol-types.d.ts +7 -7
- package/dist/util/protocol-types.js +3 -3
- package/package.json +5 -5
- package/src/entry/commands/compact-action.ts +1 -1
- package/src/entry/commands/config-command.ts +1 -1
- package/src/migrations/PowerSyncMigrationManager.ts +2 -1
- package/src/replication/AbstractReplicator.ts +2 -2
- package/src/replication/ReplicationModule.ts +0 -10
- package/src/routes/endpoints/admin.ts +1 -1
- package/src/routes/endpoints/socket-route.ts +9 -6
- package/src/storage/BucketStorageFactory.ts +11 -2
- package/src/storage/StorageVersionConfig.ts +0 -5
- package/src/storage/SyncRulesBucketStorage.ts +2 -0
- package/src/storage/implementation/IncrementalReprocessingCompatibility.ts +4 -4
- package/src/sync/BucketChecksumState.ts +5 -4
- package/src/util/param-logging.ts +21 -14
- package/src/util/protocol-types.ts +5 -5
- package/test/src/broadcast_iterable.test.ts +26 -8
- package/test/src/sync/BucketChecksumState.test.ts +195 -6
- package/test/src/util/param-logging.test.ts +59 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
|
8
|
-
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1246
|
-
// Create 105
|
|
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
|
|
1305
|
-
expect(errorMessage).toContain('... and 5 more buckets from 5
|
|
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
|
|
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
|
});
|
|
@@ -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
|
+
});
|