@powersync/service-core 1.26.1 → 1.27.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 (90) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/api/RouteAPI.d.ts +8 -1
  3. package/dist/api/api-index.d.ts +1 -0
  4. package/dist/api/api-index.js +1 -0
  5. package/dist/api/api-index.js.map +1 -1
  6. package/dist/api/diagnostics.js +33 -16
  7. package/dist/api/diagnostics.js.map +1 -1
  8. package/dist/api/source-capabilities.d.ts +7 -0
  9. package/dist/api/source-capabilities.js +23 -0
  10. package/dist/api/source-capabilities.js.map +1 -0
  11. package/dist/auth/JwtPayload.d.ts +2 -2
  12. package/dist/auth/JwtPayload.js +2 -2
  13. package/dist/auth/JwtPayload.js.map +1 -1
  14. package/dist/entry/commands/compact-action.js +1 -1
  15. package/dist/entry/commands/compact-action.js.map +1 -1
  16. package/dist/entry/commands/config-command.js +1 -1
  17. package/dist/entry/commands/config-command.js.map +1 -1
  18. package/dist/migrations/PowerSyncMigrationManager.js +2 -1
  19. package/dist/migrations/PowerSyncMigrationManager.js.map +1 -1
  20. package/dist/replication/AbstractReplicationJob.d.ts +4 -6
  21. package/dist/replication/AbstractReplicationJob.js.map +1 -1
  22. package/dist/replication/AbstractReplicator.d.ts +15 -0
  23. package/dist/replication/AbstractReplicator.js +37 -5
  24. package/dist/replication/AbstractReplicator.js.map +1 -1
  25. package/dist/replication/ReplicationModule.d.ts +0 -7
  26. package/dist/replication/ReplicationModule.js +13 -3
  27. package/dist/replication/ReplicationModule.js.map +1 -1
  28. package/dist/routes/configure-fastify.d.ts +39 -27
  29. package/dist/routes/endpoints/admin.d.ts +78 -54
  30. package/dist/routes/endpoints/admin.js +24 -10
  31. package/dist/routes/endpoints/admin.js.map +1 -1
  32. package/dist/routes/endpoints/socket-route.js +7 -5
  33. package/dist/routes/endpoints/socket-route.js.map +1 -1
  34. package/dist/routes/endpoints/sync-rules.js +39 -6
  35. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  36. package/dist/storage/BucketStorageFactory.d.ts +20 -3
  37. package/dist/storage/BucketStorageFactory.js +25 -2
  38. package/dist/storage/BucketStorageFactory.js.map +1 -1
  39. package/dist/storage/PersistedSyncConfigContent.d.ts +2 -1
  40. package/dist/storage/PersistedSyncConfigContent.js.map +1 -1
  41. package/dist/storage/StorageProvider.d.ts +3 -0
  42. package/dist/storage/StorageVersionConfig.d.ts +0 -4
  43. package/dist/storage/StorageVersionConfig.js +0 -4
  44. package/dist/storage/StorageVersionConfig.js.map +1 -1
  45. package/dist/storage/SyncRulesBucketStorage.d.ts +3 -1
  46. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  47. package/dist/storage/implementation/IncrementalReprocessingCompatibility.js +9 -5
  48. package/dist/storage/implementation/IncrementalReprocessingCompatibility.js.map +1 -1
  49. package/dist/sync/BucketChecksumState.d.ts +1 -1
  50. package/dist/sync/BucketChecksumState.js +5 -4
  51. package/dist/sync/BucketChecksumState.js.map +1 -1
  52. package/dist/util/param-logging.d.ts +5 -2
  53. package/dist/util/param-logging.js +17 -9
  54. package/dist/util/param-logging.js.map +1 -1
  55. package/dist/util/protocol-types.d.ts +7 -7
  56. package/dist/util/protocol-types.js +3 -3
  57. package/package.json +11 -11
  58. package/src/api/RouteAPI.ts +9 -1
  59. package/src/api/api-index.ts +1 -0
  60. package/src/api/diagnostics.ts +30 -14
  61. package/src/api/source-capabilities.ts +24 -0
  62. package/src/auth/JwtPayload.ts +2 -2
  63. package/src/entry/commands/compact-action.ts +1 -1
  64. package/src/entry/commands/config-command.ts +1 -1
  65. package/src/migrations/PowerSyncMigrationManager.ts +2 -1
  66. package/src/replication/AbstractReplicationJob.ts +2 -4
  67. package/src/replication/AbstractReplicator.ts +46 -5
  68. package/src/replication/ReplicationModule.ts +12 -12
  69. package/src/routes/endpoints/admin.ts +29 -11
  70. package/src/routes/endpoints/socket-route.ts +9 -6
  71. package/src/routes/endpoints/sync-rules.ts +42 -9
  72. package/src/storage/BucketStorageFactory.ts +33 -4
  73. package/src/storage/PersistedSyncConfigContent.ts +3 -0
  74. package/src/storage/StorageProvider.ts +3 -0
  75. package/src/storage/StorageVersionConfig.ts +0 -5
  76. package/src/storage/SyncRulesBucketStorage.ts +3 -1
  77. package/src/storage/implementation/IncrementalReprocessingCompatibility.ts +17 -5
  78. package/src/sync/BucketChecksumState.ts +5 -4
  79. package/src/util/param-logging.ts +21 -14
  80. package/src/util/protocol-types.ts +5 -5
  81. package/test/src/AbstractReplicator.test.ts +156 -0
  82. package/test/src/broadcast_iterable.test.ts +26 -8
  83. package/test/src/diagnostics.test.ts +128 -0
  84. package/test/src/routes/admin.test.ts +235 -4
  85. package/test/src/routes/sync-rules.test.ts +124 -0
  86. package/test/src/source_table_config.test.ts +104 -0
  87. package/test/src/storage/BucketStorageFactory.test.ts +46 -0
  88. package/test/src/sync/BucketChecksumState.test.ts +195 -6
  89. package/test/src/util/param-logging.test.ts +59 -0
  90. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,104 @@
1
+ import { isCompatible, parsePersistedSyncConfigContent, updateSyncRulesFromConfig } from '@/storage/storage-index.js';
2
+ import { logger } from '@powersync/lib-services-framework';
3
+ import { PrecompiledSyncConfig, SqlSyncRules } from '@powersync/service-sync-rules';
4
+ import { describe, expect, test } from 'vitest';
5
+
6
+ const CONFIGURED = /* yaml */ `
7
+ # sync config fixture.
8
+ config:
9
+ edition: 3
10
+ source_table_options:
11
+ orders:
12
+ mongodb_filter_expression: { $eq: ['$$doc.active', true] }
13
+ streams:
14
+ orders:
15
+ query: SELECT * FROM orders
16
+ `;
17
+
18
+ describe('source-table options', () => {
19
+ test('preserves filters through compiled storage', () => {
20
+ const parsed = SqlSyncRules.fromYaml(CONFIGURED, { defaultSchema: 'app' });
21
+ const update = updateSyncRulesFromConfig(parsed);
22
+ const restored = parsePersistedSyncConfigContent({
23
+ content: CONFIGURED,
24
+ compiledPlan: update.config.plan!,
25
+ storageVersion: 2,
26
+ parseOptions: { defaultSchema: 'app' }
27
+ });
28
+ expect((restored.config as PrecompiledSyncConfig).plan.sourceTableConfig).toEqual({
29
+ orders: { mongodb_filter_expression: { $eq: ['$$doc.active', true] } }
30
+ });
31
+ });
32
+ test('reports invalid expressions before storage', () => {
33
+ expect(() =>
34
+ SqlSyncRules.fromYaml(CONFIGURED.replace('$$doc.active', '$doc.active'), {
35
+ defaultSchema: 'app'
36
+ })
37
+ ).toThrow('Expected a source field');
38
+ });
39
+ test.each([
40
+ ['a..orders: {}', 'Source table patterns must use'],
41
+ ['Users: {}, users: {}', 'resolve to the same pattern']
42
+ ])('persists fatal diagnostics for invalid source-table options: %s', (options, message) => {
43
+ const yaml = /* yaml */ `
44
+ # sync config fixture.
45
+ config:
46
+ edition: 3
47
+ source_table_options: { ${options} }
48
+ streams:
49
+ orders:
50
+ query: SELECT * FROM orders
51
+ `;
52
+ const parsed = SqlSyncRules.fromYaml(yaml, { defaultSchema: 'app', throwOnError: false });
53
+ expect(parsed.errors).toContainEqual(
54
+ expect.objectContaining({ type: 'fatal', message: expect.stringContaining(message) })
55
+ );
56
+ const diagnostic = parsed.errors.find((error) => error.type === 'fatal' && error.message.includes(message))!;
57
+ const offendingKey = options.startsWith('a..') ? 'a..orders' : 'users';
58
+ expect(yaml.slice(diagnostic.location.start, diagnostic.location.end)).toContain(offendingKey);
59
+ const update = updateSyncRulesFromConfig(parsed);
60
+ expect(update.config.yaml).toBe(yaml);
61
+ const restored = parsePersistedSyncConfigContent({
62
+ content: update.config.yaml,
63
+ compiledPlan: update.config.plan!,
64
+ storageVersion: 2,
65
+ parseOptions: { defaultSchema: 'app' }
66
+ });
67
+ expect(restored.errors.map(({ message, type, location }) => ({ message, type, location }))).toEqual(
68
+ parsed.errors.map(({ message, type, location }) => ({ message, type, location }))
69
+ );
70
+ expect((restored.config as PrecompiledSyncConfig).plan.sourceTableConfig ?? {}).toEqual({});
71
+ });
72
+
73
+ test('keeps valid filters on a partial plan with an unrelated SQL error', () => {
74
+ const yaml = CONFIGURED.replace('SELECT * FROM orders', 'SELECT FROM');
75
+ const parsed = SqlSyncRules.fromYaml(yaml, { defaultSchema: 'app', throwOnError: false });
76
+ expect(parsed.errors.some((error) => error.type === 'fatal')).toBe(true);
77
+ const update = updateSyncRulesFromConfig(parsed);
78
+ expect(update.config.plan!.plan.sourceTableConfig).toEqual({
79
+ orders: { mongodb_filter_expression: { $eq: ['$$doc.active', true] } }
80
+ });
81
+ });
82
+ test('requires replacement processing when filters change or are removed', () => {
83
+ const deploy = (yaml: string) => updateSyncRulesFromConfig(SqlSyncRules.fromYaml(yaml, { defaultSchema: 'app' }));
84
+ const first = deploy(CONFIGURED);
85
+ const same = deploy(CONFIGURED.replace('SELECT *', 'SELECT id'));
86
+ const changed = deploy(CONFIGURED.replace('true', 'false'));
87
+ const removed = deploy(
88
+ CONFIGURED.replace(
89
+ "mongodb_filter_expression: { $eq: ['$$doc.active', true] }",
90
+ 'mongodb_filter_expression: disabled'
91
+ )
92
+ );
93
+ expect(isCompatible([first.config.plan], same.config, logger)).toBe(true);
94
+ expect(isCompatible([first.config.plan], changed.config, logger)).toBe(false);
95
+ expect(isCompatible([first.config.plan], removed.config, logger)).toBe(false);
96
+ });
97
+ test('requires replacement processing when saved source-table options no longer validate', () => {
98
+ const update = updateSyncRulesFromConfig(SqlSyncRules.fromYaml(CONFIGURED, { defaultSchema: 'app' }));
99
+ const existing = structuredClone(update.config.plan!);
100
+ // A saved expression can become unsupported after a module or version change.
101
+ Object.assign(existing.plan.sourceTableConfig!.orders!, { mongodb_filter_expression: { $unsupported: [] } });
102
+ expect(isCompatible([existing], update.config, logger)).toBe(false);
103
+ });
104
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import * as storage from '../../../src/storage/storage-index.js';
3
+
4
+ describe('logSyncConfigErrors', () => {
5
+ function makeLogger() {
6
+ return { warn: vi.fn(), error: vi.fn() };
7
+ }
8
+
9
+ test('logs fatal errors and warnings when the sync config is not validated', () => {
10
+ // Without validation, as at startup with exit_on_error: false, parsing doesn't throw on fatal errors.
11
+ const updateOptions = storage.updateSyncRulesFromYaml(
12
+ `
13
+ bucket_definitions:
14
+ mybucket:
15
+ data:
16
+ - SELECT FROM
17
+ `,
18
+ { validate: false }
19
+ );
20
+ const log = makeLogger();
21
+
22
+ storage.logSyncConfigErrors(updateOptions.config.parsed, log as any);
23
+
24
+ expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Sync config error:'));
25
+ expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('Sync Rules (`bucket_definitions`) are deprecated'));
26
+ });
27
+
28
+ test('logs nothing for a valid Sync Streams config', () => {
29
+ const updateOptions = storage.updateSyncRulesFromYaml(
30
+ `
31
+ config:
32
+ edition: 3
33
+ streams:
34
+ global:
35
+ query: SELECT id FROM test
36
+ `,
37
+ { validate: true }
38
+ );
39
+ const log = makeLogger();
40
+
41
+ storage.logSyncConfigErrors(updateOptions.config.parsed, log as any);
42
+
43
+ expect(log.error).not.toHaveBeenCalled();
44
+ expect(log.warn).not.toHaveBeenCalled();
45
+ });
46
+ });
@@ -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
  });
@@ -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
+ });