Skip to content

Commit 6b057c3

Browse files
Merge pull request #8 from githyperplexed/feat/search-optimization
Search optimization via indices
2 parents 0a38006 + 27e2853 commit 6b057c3

13 files changed

Lines changed: 520 additions & 173 deletions

File tree

src/lib/stores/catalog.svelte.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export class Catalog {
3535
this.total = message.count;
3636
this.search(this.query, 0, 40);
3737
} else if (message.type === "results") {
38-
if (message.id === this.requestId) {
38+
if (message.id === this.requestId && message.id !== undefined) {
3939
this.results = message.results;
4040
this.count = message.total;
4141
this.offset = message.offset;

src/lib/types/worker.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { z } from "zod";
22

3-
export const WorkerMessageTypeSchema = z.enum(["ready", "results", "error", "load", "search"]);
3+
import { StoreSchema } from "$lib/types/store";
4+
5+
export const WorkerMessageTypeSchema = z.enum(["load", "search", "ready", "results", "error"]);
46

57
export type WorkerMessageType = z.infer<typeof WorkerMessageTypeSchema>;
68

@@ -9,8 +11,6 @@ const ReadyMessageSchema = z.object({
911
count: z.number()
1012
});
1113

12-
import { StoreSchema } from "$lib/types/store";
13-
1414
const ResultsMessageSchema = z.object({
1515
type: z.literal("results"),
1616
results: z.array(StoreSchema),
@@ -55,3 +55,31 @@ export const MainToWorkerMessageSchema = z.discriminatedUnion("type", [
5555

5656
export type WorkerToMainMessage = z.infer<typeof WorkerToMainMessageSchema>;
5757
export type MainToWorkerMessage = z.infer<typeof MainToWorkerMessageSchema>;
58+
59+
export const SearchStateSchema = z.object({
60+
current: z.string().nullable()
61+
});
62+
63+
export const SortStateSchema = z.object({
64+
key: z.string().nullable(),
65+
direction: z.enum(["asc", "desc"]).nullable()
66+
});
67+
68+
export const WorkerStateSchema = z.object({
69+
query: SearchStateSchema,
70+
sort: SortStateSchema
71+
});
72+
73+
export const WorkerContextSchema = z.object({
74+
dataset: z.array(StoreSchema),
75+
keys: z.array(z.string()),
76+
indices: z.object({
77+
all: z.array(z.number()),
78+
matched: z.array(z.number())
79+
})
80+
});
81+
82+
export type SearchState = z.infer<typeof SearchStateSchema>;
83+
export type SortState = z.infer<typeof SortStateSchema>;
84+
export type WorkerState = z.infer<typeof WorkerStateSchema>;
85+
export type WorkerContext = z.infer<typeof WorkerContextSchema>;

src/lib/utilities/format.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect } from "vitest";
22

3-
import { formatNumber, formatCurrency, formatDate, formatDateTime } from "./format";
3+
import { formatNumber, formatCurrency, formatDate, formatDateTime } from "$lib/utilities/format";
44

55
describe("formatNumber", () => {
66
it("formats small numbers as-is", () => {

src/lib/utilities/pipeline.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { describe, it, expect } from "vitest";
2+
3+
import { pipeline } from "$lib/utilities/pipeline";
4+
5+
import type { Store } from "$lib/types/store";
6+
import type { WorkerState, WorkerContext } from "$lib/types/worker";
7+
8+
const createStore = (overrides: Partial<Store> = {}): Store => ({
9+
l: "",
10+
n: "",
11+
u: "",
12+
c: "",
13+
s30: 0,
14+
sav: 0,
15+
aff: "",
16+
cc: 0,
17+
...overrides
18+
});
19+
20+
const stores = [
21+
createStore({ n: "A" }),
22+
createStore({ n: "B" }),
23+
createStore({ n: "C" })
24+
];
25+
26+
const createContext = (overrides: Partial<WorkerContext> = {}): WorkerContext => ({
27+
dataset: stores,
28+
keys: ["a", "b", "c"],
29+
indices: {
30+
all: [0, 1, 2],
31+
matched: [0, 1, 2]
32+
},
33+
...overrides
34+
});
35+
36+
const createState = (overrides: Partial<WorkerState> = {}): WorkerState => ({
37+
query: { current: "" },
38+
sort: { key: null, direction: null },
39+
...overrides
40+
});
41+
42+
describe("pipeline", () => {
43+
describe("filter", () => {
44+
it("returns existing matched indices if query has not changed", () => {
45+
const context = createContext({ indices: { all: [0, 1, 2], matched: [0] } });
46+
const state = createState({ query: { current: "a" } });
47+
48+
const result = pipeline.filter("a", state, context);
49+
50+
expect(result.filtered).toBe(false);
51+
expect(result.indices).toBe(context.indices.matched);
52+
});
53+
54+
it("calculates new indices if query changed", () => {
55+
const context = createContext();
56+
const state = createState({ query: { current: "" } });
57+
58+
const result = pipeline.filter("b", state, context);
59+
60+
expect(result.filtered).toBe(true);
61+
expect(result.indices).toHaveLength(1);
62+
expect(stores[result.indices[0]].n).toBe("B");
63+
});
64+
});
65+
66+
describe("sort", () => {
67+
it("returns indices as is if not filtered and sort has not changed", () => {
68+
const indices = [0, 1, 2];
69+
const state = createState({ sort: { key: "info", direction: "asc" } });
70+
71+
// Same sort params
72+
const result = pipeline.sort(indices, stores, { key: "info", direction: "asc" }, state, false);
73+
74+
expect(result).toBe(indices);
75+
});
76+
77+
it("sorts if filtered is true even if sort params haven't changed", () => {
78+
const indices = [1, 0];
79+
const state = createState({ sort: { key: "info", direction: "asc" } });
80+
81+
const result = pipeline.sort(indices, stores, { key: "info", direction: "asc" }, state, true);
82+
83+
expect(result).not.toBe(indices);
84+
expect(result).toEqual([0, 1]);
85+
});
86+
87+
it("sorts if sort params changed", () => {
88+
const indices = [0, 1, 2];
89+
const state = createState({ sort: { key: "info", direction: "asc" } });
90+
91+
const result = pipeline.sort(indices, stores, { key: "info", direction: "desc" }, state, false);
92+
93+
expect(result).toEqual([2, 1, 0]);
94+
});
95+
96+
it("restores natural order when sort is removed", () => {
97+
const context = createContext();
98+
// indices: [0, 1, 2] (A, B, C)
99+
100+
// 1. Sort descending
101+
const state = createState({ sort: { key: null, direction: null } });
102+
103+
// Apply sort
104+
const sortedIndices = pipeline.sort(
105+
context.indices.matched,
106+
stores,
107+
{ key: "info", direction: "desc" },
108+
state,
109+
false
110+
);
111+
112+
expect(sortedIndices).toEqual([2, 1, 0]); // C, B, A
113+
114+
// Update state (simulating worker)
115+
state.sort.key = "info";
116+
state.sort.direction = "desc";
117+
118+
// 2. Remove sort
119+
const currentIndices = [...sortedIndices];
120+
121+
const unsortedIndices = pipeline.sort(
122+
currentIndices,
123+
stores,
124+
undefined,
125+
state,
126+
false
127+
);
128+
129+
expect(unsortedIndices).toEqual([0, 1, 2]);
130+
});
131+
});
132+
133+
describe("paginate", () => {
134+
it("returns slice of dataset based on indices", () => {
135+
const indices = [2, 0]; // C, A
136+
const result = pipeline.paginate(indices, stores, 0, 10);
137+
138+
expect(result).toHaveLength(2);
139+
expect(result[0].n).toBe("C");
140+
expect(result[1].n).toBe("A");
141+
});
142+
143+
it("handles offset and limit", () => {
144+
const indices = [0, 1, 2];
145+
const result = pipeline.paginate(indices, stores, 1, 1);
146+
147+
expect(result).toHaveLength(1);
148+
expect(result[0].n).toBe("B");
149+
});
150+
});
151+
});

src/lib/utilities/pipeline.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { util } from "$lib/utilities/worker";
2+
3+
import type { Store } from "$lib/types/store";
4+
import type { SortState } from "$lib/types/table";
5+
import type { WorkerState, WorkerContext } from "$lib/types/worker";
6+
7+
const filter = (
8+
query: string,
9+
state: WorkerState,
10+
context: WorkerContext
11+
): { indices: number[], filtered: boolean } => {
12+
const changed = query !== state.query.current;
13+
14+
if (changed) {
15+
const indices = util.filter(query, state, context);
16+
17+
return { indices, filtered: true };
18+
}
19+
20+
return { indices: context.indices.matched, filtered: false };
21+
};
22+
23+
const sort = (
24+
indices: number[],
25+
dataset: Store[],
26+
sort: SortState | undefined,
27+
state: WorkerState,
28+
filtered: boolean
29+
): number[] => {
30+
const changed = (
31+
sort?.key !== state.sort.key ||
32+
sort?.direction !== state.sort.direction
33+
);
34+
35+
if (sort?.key) {
36+
if (filtered || changed) {
37+
return util.sort(indices, dataset, sort);
38+
}
39+
40+
return indices;
41+
}
42+
43+
if (!filtered && state.sort.key) {
44+
return [...indices].sort((a, b) => a - b);
45+
}
46+
47+
return indices;
48+
};
49+
50+
const paginate = (
51+
indices: number[],
52+
dataset: Store[],
53+
offset: number,
54+
limit: number
55+
): Store[] => {
56+
return indices
57+
.slice(offset, offset + limit)
58+
.map(index => dataset[index]);
59+
};
60+
61+
export const pipeline = {
62+
filter,
63+
sort,
64+
paginate
65+
}

src/lib/utilities/search.test.ts

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -66,64 +66,66 @@ describe("findMatches", () => {
6666
createStore({ n: "Target", u: "target.com", c: "US" })
6767
];
6868
const keys = createSearchKeys(stores);
69+
const allIndices = [0, 1, 2, 3, 4];
6970

7071
it("returns all items when query is empty", () => {
71-
const results = findMatches(stores, keys, "", 100);
72+
const results = findMatches(allIndices, keys, "", 100);
7273
expect(results).toHaveLength(5);
74+
expect(results).toEqual(allIndices);
7375
});
7476

7577
it("matches single term case-insensitively", () => {
76-
const results = findMatches(stores, keys, "amazon", 100);
78+
const results = findMatches(allIndices, keys, "amazon", 100);
7779
expect(results).toHaveLength(2);
78-
expect(results[0].n).toBe("Amazon");
79-
expect(results[1].n).toBe("Amazon UK");
80+
expect(stores[results[0]].n).toBe("Amazon");
81+
expect(stores[results[1]].n).toBe("Amazon UK");
8082
});
8183

8284
it("matches multiple terms (AND logic)", () => {
83-
const results = findMatches(stores, keys, "amazon uk", 100);
85+
const results = findMatches(allIndices, keys, "amazon uk", 100);
8486
expect(results).toHaveLength(1);
85-
expect(results[0].n).toBe("Amazon UK");
87+
expect(stores[results[0]].n).toBe("Amazon UK");
8688
});
8789

8890
it("matches by URL", () => {
89-
const results = findMatches(stores, keys, "bestbuy.com", 100);
91+
const results = findMatches(allIndices, keys, "bestbuy.com", 100);
9092
expect(results).toHaveLength(1);
91-
expect(results[0].n).toBe("Best Buy");
93+
expect(stores[results[0]].n).toBe("Best Buy");
9294
});
9395

9496
it("matches by country", () => {
95-
const results = findMatches(stores, keys, "uk", 100);
97+
const results = findMatches(allIndices, keys, "uk", 100);
9698
expect(results).toHaveLength(1);
97-
expect(results[0].n).toBe("Amazon UK");
99+
expect(stores[results[0]].n).toBe("Amazon UK");
98100
});
99101

100102
it("respects maxResults limit", () => {
101-
const results = findMatches(stores, keys, "us", 2);
103+
const results = findMatches(allIndices, keys, "us", 2);
102104
expect(results).toHaveLength(2);
103105
});
104106

105107
it("returns empty array when no matches", () => {
106-
const results = findMatches(stores, keys, "nonexistent", 100);
108+
const results = findMatches(allIndices, keys, "nonexistent", 100);
107109
expect(results).toHaveLength(0);
108110
});
109111

110112
it("handles whitespace-only query as empty", () => {
111-
const results = findMatches(stores, keys, " ", 100);
113+
const results = findMatches(allIndices, keys, " ", 100);
112114
expect(results).toHaveLength(5);
113115
});
114116

115117
it("handles mixed case queries", () => {
116-
const results = findMatches(stores, keys, "AMAZON", 100);
118+
const results = findMatches(allIndices, keys, "AMAZON", 100);
117119
expect(results).toHaveLength(2);
118120
});
119121

120122
it("handles partial word matches", () => {
121-
const results = findMatches(stores, keys, "ama", 100);
123+
const results = findMatches(allIndices, keys, "ama", 100);
122124
expect(results).toHaveLength(2);
123125
});
124126

125127
it("returns slice of items when query empty and maxResults set", () => {
126-
const results = findMatches(stores, keys, "", 3);
128+
const results = findMatches(allIndices, keys, "", 3);
127129
expect(results).toHaveLength(3);
128130
});
129131
});

0 commit comments

Comments
 (0)