@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.
- package/CHANGELOG.md +34 -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/sync/util.d.ts +1 -1
- package/dist/sync/util.js +3 -0
- package/dist/sync/util.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/sync/util.ts +4 -1
- 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/sync/util.test.ts +19 -0
- package/test/src/util/param-logging.test.ts +59 -0
- 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
|
-
*
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
257
|
-
* association is useful because it enables clients to track progress for individual
|
|
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
|
|
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
|
});
|
|
@@ -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
|
+
});
|