Skip to content

Commit f8f7b6d

Browse files
improve readonly mutability in types. Persist previous object references better in differential queries.
1 parent a913020 commit f8f7b6d

File tree

4 files changed

+125
-24
lines changed

4 files changed

+125
-24
lines changed

packages/common/src/client/watched/WatchedQuery.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export interface WatchedQueryState<Data> {
2828
/**
2929
* The last data returned by the query.
3030
*/
31-
data: Data;
31+
readonly data: Data;
3232
}
3333

3434
/**

packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,34 @@ import { AbstractQueryProcessor, AbstractQueryProcessorOptions, LinkQueryOptions
66
* It contains both the current and previous state of the row.
77
*/
88
export interface WatchedQueryRowDifferential<RowType> {
9-
current: RowType;
10-
previous: RowType;
9+
readonly current: RowType;
10+
readonly previous: RowType;
1111
}
1212

1313
/**
1414
* Represents the result of a watched query that has been differentiated.
1515
* {@link WatchedQueryState.data} is of the {@link WatchedQueryDifferential} form when using the {@link IncrementalWatchMode.DIFFERENTIAL} mode.
1616
*/
1717
export interface WatchedQueryDifferential<RowType> {
18-
added: RowType[];
19-
all: RowType[];
20-
removed: RowType[];
21-
updated: WatchedQueryRowDifferential<RowType>[];
22-
unchanged: RowType[];
18+
added: ReadonlyArray<RowType>;
19+
/**
20+
* The entire current result set.
21+
* Array item object references are preserved between updates if the item is unchanged.
22+
*
23+
* e.g. In the query
24+
* ```sql
25+
* SELECT name, make FROM assets ORDER BY make ASC;
26+
* ```
27+
*
28+
* If a previous result set contains an item (A) `{name: 'pc', make: 'Cool PC'}` and
29+
* an update has been made which adds another item (B) to the result set (the item A is unchanged) - then
30+
* the updated result set will be contain the same object reference,to item A, as the previous resultset.
31+
* This is regardless of the item A's position in the updated result set.
32+
*/
33+
all: ReadonlyArray<RowType>;
34+
removed: ReadonlyArray<RowType>;
35+
updated: ReadonlyArray<WatchedQueryRowDifferential<RowType>>;
36+
unchanged: ReadonlyArray<RowType>;
2337
}
2438

2539
/**
@@ -98,36 +112,44 @@ export class DifferentialQueryProcessor<RowType>
98112

99113
let hasChanged = false;
100114
const currentMap = new Map<string, { hash: string; item: RowType }>();
101-
current.forEach((item) => {
102-
currentMap.set(identify(item), {
103-
hash: compareBy(item),
104-
item
105-
});
106-
});
107-
108115
const removedTracker = new Set(previousMap.keys());
109116

110-
const diff: WatchedQueryDifferential<RowType> = {
111-
all: current,
112-
added: [],
113-
removed: [],
114-
updated: [],
115-
unchanged: []
117+
// Allow mutating to populate the data temporarily.
118+
const diff = {
119+
all: [] as RowType[],
120+
added: [] as RowType[],
121+
removed: [] as RowType[],
122+
updated: [] as WatchedQueryRowDifferential<RowType>[],
123+
unchanged: [] as RowType[]
116124
};
117125

118-
for (const [key, { hash, item }] of currentMap) {
126+
/**
127+
* Looping over the current result set array is important to preserve
128+
* the ordering of the result set.
129+
* We can replace items in the current array with previous object references if they are equal.
130+
*/
131+
for (const item of current) {
132+
const key = identify(item);
133+
const hash = compareBy(item);
134+
currentMap.set(key, { hash, item });
135+
119136
const previousItem = previousMap.get(key);
120137
if (!previousItem) {
121138
// New item
122139
hasChanged = true;
123140
diff.added.push(item);
141+
diff.all.push(item);
124142
} else {
125143
// Existing item
126144
if (hash == previousItem.hash) {
127145
diff.unchanged.push(item);
146+
// Use the previous object reference
147+
diff.all.push(previousItem.item);
128148
} else {
129149
hasChanged = true;
130150
diff.updated.push({ current: item, previous: previousItem.item });
151+
// Use the new reference
152+
diff.all.push(item);
131153
}
132154
}
133155
// The item is present, we don't consider it removed
@@ -175,7 +197,9 @@ export class DifferentialQueryProcessor<RowType>
175197
await this.updateState({ isFetching: true });
176198
}
177199

178-
const partialStateUpdate: Partial<WatchedQueryState<WatchedQueryDifferential<RowType>>> = {};
200+
const partialStateUpdate: Partial<WatchedQueryState<WatchedQueryDifferential<RowType>>> & {
201+
data?: WatchedQueryDifferential<RowType>;
202+
} = {};
179203

180204
// Always run the query if an underlying table has changed
181205
const result = await watchOptions.query.execute({

packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export class OnChangeQueryProcessor<Data> extends AbstractQueryProcessor<Data, C
5656
await this.updateState({ isFetching: true });
5757
}
5858

59-
const partialStateUpdate: Partial<WatchedQueryState<Data>> = {};
59+
const partialStateUpdate: Partial<WatchedQueryState<Data>> & { data?: Data } = {};
6060

6161
// Always run the query if an underlying table has changed
6262
const result = await watchOptions.query.execute({

packages/web/tests/watch.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,83 @@ describe('Watch Tests', { sequential: true }, () => {
668668
);
669669
});
670670

671+
it('should preserve object references in result set', async () => {
672+
// Sort the results by the `make` column in ascending order
673+
const watch = powersync
674+
.incrementalWatch({
675+
mode: IncrementalWatchMode.DIFFERENTIAL
676+
})
677+
.build({
678+
differentiator: {
679+
identify: (item) => item.id,
680+
compareBy: (item) => JSON.stringify(item)
681+
},
682+
watch: {
683+
query: new GetAllQuery({
684+
sql: /* sql */ `
685+
SELECT
686+
*
687+
FROM
688+
assets
689+
ORDER BY
690+
make ASC;
691+
`,
692+
mapper: (raw) => {
693+
return {
694+
id: raw.id as string,
695+
make: raw.make as string
696+
};
697+
}
698+
})
699+
}
700+
});
701+
702+
// Create sample data
703+
await powersync.execute(
704+
/* sql */ `
705+
INSERT INTO
706+
assets (id, make, customer_id)
707+
VALUES
708+
(uuid (), ?, uuid ()),
709+
(uuid (), ?, uuid ()),
710+
(uuid (), ?, uuid ())
711+
`,
712+
['a', 'b', 'd']
713+
);
714+
715+
await vi.waitFor(
716+
() => {
717+
expect(watch.state.data.all.map((i) => i.make)).deep.equals(['a', 'b', 'd']);
718+
},
719+
{ timeout: 1000 }
720+
);
721+
722+
const initialData = watch.state.data.all;
723+
724+
await powersync.execute(
725+
/* sql */ `
726+
INSERT INTO
727+
assets (id, make, customer_id)
728+
VALUES
729+
(uuid (), ?, uuid ())
730+
`,
731+
['c']
732+
);
733+
734+
await vi.waitFor(
735+
() => {
736+
expect(watch.state.data.all).toHaveLength(4);
737+
},
738+
{ timeout: 1000 }
739+
);
740+
741+
console.log(JSON.stringify(watch.state.data.all));
742+
expect(initialData[0] == watch.state.data.all[0]).true;
743+
expect(initialData[1] == watch.state.data.all[1]).true;
744+
// The index after the insert should also still be the same ref as the previous item
745+
expect(initialData[2] == watch.state.data.all[3]).true;
746+
});
747+
671748
it('should report differential query results from initial state', async () => {
672749
/**
673750
* Differential queries start with a placeholder data. We run a watched query under the hood

0 commit comments

Comments
 (0)