@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.
- package/CHANGELOG.md +67 -0
- package/dist/api/RouteAPI.d.ts +8 -1
- package/dist/api/api-index.d.ts +1 -0
- package/dist/api/api-index.js +1 -0
- package/dist/api/api-index.js.map +1 -1
- package/dist/api/diagnostics.js +33 -16
- package/dist/api/diagnostics.js.map +1 -1
- package/dist/api/source-capabilities.d.ts +7 -0
- package/dist/api/source-capabilities.js +23 -0
- package/dist/api/source-capabilities.js.map +1 -0
- package/dist/auth/JwtPayload.d.ts +2 -2
- package/dist/auth/JwtPayload.js +2 -2
- package/dist/auth/JwtPayload.js.map +1 -1
- 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/AbstractReplicationJob.d.ts +4 -6
- package/dist/replication/AbstractReplicationJob.js.map +1 -1
- package/dist/replication/AbstractReplicator.d.ts +15 -0
- package/dist/replication/AbstractReplicator.js +37 -5
- package/dist/replication/AbstractReplicator.js.map +1 -1
- package/dist/replication/ReplicationModule.d.ts +0 -7
- package/dist/replication/ReplicationModule.js +13 -3
- package/dist/replication/ReplicationModule.js.map +1 -1
- package/dist/routes/configure-fastify.d.ts +39 -27
- package/dist/routes/endpoints/admin.d.ts +78 -54
- package/dist/routes/endpoints/admin.js +24 -10
- 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/routes/endpoints/sync-rules.js +39 -6
- package/dist/routes/endpoints/sync-rules.js.map +1 -1
- package/dist/storage/BucketStorageFactory.d.ts +20 -3
- package/dist/storage/BucketStorageFactory.js +25 -2
- package/dist/storage/BucketStorageFactory.js.map +1 -1
- package/dist/storage/PersistedSyncConfigContent.d.ts +2 -1
- package/dist/storage/PersistedSyncConfigContent.js.map +1 -1
- package/dist/storage/StorageProvider.d.ts +3 -0
- 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 +3 -1
- package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
- package/dist/storage/implementation/IncrementalReprocessingCompatibility.js +9 -5
- package/dist/storage/implementation/IncrementalReprocessingCompatibility.js.map +1 -1
- 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 +11 -11
- package/src/api/RouteAPI.ts +9 -1
- package/src/api/api-index.ts +1 -0
- package/src/api/diagnostics.ts +30 -14
- package/src/api/source-capabilities.ts +24 -0
- package/src/auth/JwtPayload.ts +2 -2
- 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/AbstractReplicationJob.ts +2 -4
- package/src/replication/AbstractReplicator.ts +46 -5
- package/src/replication/ReplicationModule.ts +12 -12
- package/src/routes/endpoints/admin.ts +29 -11
- package/src/routes/endpoints/socket-route.ts +9 -6
- package/src/routes/endpoints/sync-rules.ts +42 -9
- package/src/storage/BucketStorageFactory.ts +33 -4
- package/src/storage/PersistedSyncConfigContent.ts +3 -0
- package/src/storage/StorageProvider.ts +3 -0
- package/src/storage/StorageVersionConfig.ts +0 -5
- package/src/storage/SyncRulesBucketStorage.ts +3 -1
- package/src/storage/implementation/IncrementalReprocessingCompatibility.ts +17 -5
- 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/AbstractReplicator.test.ts +156 -0
- package/test/src/broadcast_iterable.test.ts +26 -8
- package/test/src/diagnostics.test.ts +128 -0
- package/test/src/routes/admin.test.ts +235 -4
- package/test/src/routes/sync-rules.test.ts +124 -0
- package/test/src/source_table_config.test.ts +104 -0
- package/test/src/storage/BucketStorageFactory.test.ts +46 -0
- package/test/src/sync/BucketChecksumState.test.ts +195 -6
- package/test/src/util/param-logging.test.ts +59 -0
- 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
|
|
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
|
+
});
|