Skip to content

Commit ae38d55

Browse files
guyofeckclaude
andcommitted
feat(utils): Add WatchListManager for client-side sorting optimization
This adds a utility class for maintaining a sorted list of entities from watch() subscription events, optimizing for common operations like insertions in sorted order and removals. Features: - WatchListManager class for managing sorted watch results - createComparatorFromSort helper for creating sort comparators from strings - Support for limit enforcement (truncates list to limit) - Binary search insertion for O(log n) sorted insertions - O(1) lookup by ID using Map - Automatic re-sorting when sort field changes on update Example usage: ```typescript const manager = new WatchListManager<Task>({ sort: '-created_date', limit: 10, }); // Initialize with existing data manager.initialize(await base44.entities.Task.filter({ status: 'active' })); // Handle watch events const unsubscribe = base44.entities.Task.watch( { filter: { status: 'active' }, sort: '-created_date', limit: 10 }, (event) => { manager.handleEvent(event); renderTaskList(manager.getItems()); } ); ``` Fixes #84 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent bc54562 commit ae38d55

File tree

3 files changed

+598
-0
lines changed

3 files changed

+598
-0
lines changed

src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,20 @@ export type {
3939
RealtimeEventType,
4040
RealtimeEvent,
4141
RealtimeCallback,
42+
WatchChangeType,
43+
WatchEvent,
44+
WatchOptions,
45+
WatchCallback,
4246
} from "./modules/entities.types.js";
4347

48+
// Watch list manager for client-side sorting optimization
49+
export {
50+
WatchListManager,
51+
createComparatorFromSort,
52+
type WatchListManagerOptions,
53+
type SortComparator,
54+
} from "./utils/watch-list-manager.js";
55+
4456
export type {
4557
AuthModule,
4658
LoginResponse,

src/utils/watch-list-manager.ts

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
/**
2+
* WatchListManager - A utility for managing a sorted list from watch() subscription events.
3+
*
4+
* This class maintains a sorted list of entities based on watch events, optimizing
5+
* for common operations like insertions in sorted order and removals.
6+
*/
7+
8+
import type { WatchEvent } from "../modules/entities.types";
9+
10+
/**
11+
* Comparator function for sorting entities.
12+
*/
13+
export type SortComparator<T> = (a: T, b: T) => number;
14+
15+
/**
16+
* Creates a comparator function from a sort string.
17+
*
18+
* @param sortField - Sort field with optional '-' prefix for descending order
19+
* @returns Comparator function
20+
*
21+
* @example
22+
* ```typescript
23+
* const comparator = createComparatorFromSort('-created_date');
24+
* // Sorts by created_date in descending order
25+
* ```
26+
*/
27+
export function createComparatorFromSort<T extends Record<string, any>>(
28+
sortField: string
29+
): SortComparator<T> {
30+
const descending = sortField.startsWith("-");
31+
const field = descending ? sortField.slice(1) : sortField;
32+
33+
return (a: T, b: T) => {
34+
const aValue = a[field];
35+
const bValue = b[field];
36+
37+
// Handle undefined/null values (push to end)
38+
if (aValue == null && bValue == null) return 0;
39+
if (aValue == null) return 1;
40+
if (bValue == null) return -1;
41+
42+
// Compare values
43+
let result: number;
44+
if (typeof aValue === "string" && typeof bValue === "string") {
45+
result = aValue.localeCompare(bValue);
46+
} else if (aValue instanceof Date && bValue instanceof Date) {
47+
result = aValue.getTime() - bValue.getTime();
48+
} else {
49+
result = aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
50+
}
51+
52+
return descending ? -result : result;
53+
};
54+
}
55+
56+
/**
57+
* Configuration options for WatchListManager.
58+
*/
59+
export interface WatchListManagerOptions<T> {
60+
/** Maximum number of items to keep in the list (for limit support) */
61+
limit?: number;
62+
/** Sort comparator function or sort field string */
63+
sort?: SortComparator<T> | string;
64+
/** Function to extract the ID from an entity */
65+
getId?: (item: T) => string;
66+
}
67+
68+
/**
69+
* Manages a sorted list of entities from watch subscription events.
70+
*
71+
* Optimizes for:
72+
* - Insertions in sorted order (binary search)
73+
* - Removals by ID (Map lookup)
74+
* - Limit enforcement
75+
*
76+
* @example
77+
* ```typescript
78+
* const manager = new WatchListManager<Task>({
79+
* sort: '-created_date',
80+
* limit: 10,
81+
* });
82+
*
83+
* const unsubscribe = base44.entities.Task.watch(
84+
* { filter: { status: 'active' }, sort: '-created_date', limit: 10 },
85+
* (event) => {
86+
* manager.handleEvent(event);
87+
* // Update UI with manager.getItems()
88+
* renderTaskList(manager.getItems());
89+
* }
90+
* );
91+
* ```
92+
*/
93+
export class WatchListManager<T extends Record<string, any>> {
94+
private items: T[] = [];
95+
private itemsById: Map<string, T> = new Map();
96+
private limit?: number;
97+
private comparator?: SortComparator<T>;
98+
private getId: (item: T) => string;
99+
100+
constructor(options: WatchListManagerOptions<T> = {}) {
101+
this.limit = options.limit;
102+
this.getId = options.getId || ((item) => item.id);
103+
104+
if (options.sort) {
105+
this.comparator =
106+
typeof options.sort === "string"
107+
? createComparatorFromSort(options.sort)
108+
: options.sort;
109+
}
110+
}
111+
112+
/**
113+
* Handle a watch event and update the list accordingly.
114+
*
115+
* @param event - The watch event from the subscription
116+
* @returns Object with information about what changed
117+
*/
118+
handleEvent(event: WatchEvent): {
119+
added: boolean;
120+
removed: boolean;
121+
modified: boolean;
122+
item?: T;
123+
} {
124+
const item = event.data as T;
125+
const id = event.id || this.getId(item);
126+
127+
switch (event.changeType) {
128+
case "added":
129+
return { ...this.addItem(item, id), modified: false };
130+
131+
case "modified":
132+
return { added: false, removed: false, modified: true, item: this.updateItem(item, id) };
133+
134+
case "removed":
135+
return { added: false, removed: this.removeItem(id), modified: false };
136+
137+
default:
138+
return { added: false, removed: false, modified: false };
139+
}
140+
}
141+
142+
/**
143+
* Add an item to the list in sorted order.
144+
*/
145+
private addItem(item: T, id: string): { added: boolean; removed: boolean; item?: T } {
146+
// Check if already exists
147+
if (this.itemsById.has(id)) {
148+
// Update existing item
149+
return { added: false, removed: false, item: this.updateItem(item, id) };
150+
}
151+
152+
// Find insertion position using binary search if sorted
153+
let insertIndex = this.items.length;
154+
if (this.comparator) {
155+
insertIndex = this.findInsertionIndex(item);
156+
}
157+
158+
// Check if this item would be beyond the limit
159+
if (this.limit && insertIndex >= this.limit) {
160+
return { added: false, removed: false };
161+
}
162+
163+
// Insert the item
164+
this.items.splice(insertIndex, 0, item);
165+
this.itemsById.set(id, item);
166+
167+
// Enforce limit by removing last item if needed
168+
let removed = false;
169+
if (this.limit && this.items.length > this.limit) {
170+
const removedItem = this.items.pop();
171+
if (removedItem) {
172+
const removedId = this.getId(removedItem);
173+
this.itemsById.delete(removedId);
174+
removed = true;
175+
}
176+
}
177+
178+
return { added: true, removed, item };
179+
}
180+
181+
/**
182+
* Update an existing item, re-sorting if necessary.
183+
*/
184+
private updateItem(item: T, id: string): T | undefined {
185+
const existingItem = this.itemsById.get(id);
186+
if (!existingItem) {
187+
// Item doesn't exist, add it
188+
this.addItem(item, id);
189+
return item;
190+
}
191+
192+
// Update the item in the map
193+
this.itemsById.set(id, item);
194+
195+
// Find and update in the array
196+
const currentIndex = this.items.findIndex((i) => this.getId(i) === id);
197+
if (currentIndex === -1) return undefined;
198+
199+
// If sorted, check if position changed
200+
if (this.comparator) {
201+
// Remove from current position
202+
this.items.splice(currentIndex, 1);
203+
// Find new position
204+
const newIndex = this.findInsertionIndex(item);
205+
// Insert at new position
206+
this.items.splice(newIndex, 0, item);
207+
} else {
208+
// No sorting, just update in place
209+
this.items[currentIndex] = item;
210+
}
211+
212+
return item;
213+
}
214+
215+
/**
216+
* Remove an item from the list.
217+
*/
218+
private removeItem(id: string): boolean {
219+
if (!this.itemsById.has(id)) {
220+
return false;
221+
}
222+
223+
this.itemsById.delete(id);
224+
const index = this.items.findIndex((item) => this.getId(item) === id);
225+
if (index !== -1) {
226+
this.items.splice(index, 1);
227+
}
228+
229+
return true;
230+
}
231+
232+
/**
233+
* Find the insertion index for an item using binary search.
234+
*/
235+
private findInsertionIndex(item: T): number {
236+
if (!this.comparator || this.items.length === 0) {
237+
return this.items.length;
238+
}
239+
240+
let left = 0;
241+
let right = this.items.length;
242+
243+
while (left < right) {
244+
const mid = Math.floor((left + right) / 2);
245+
if (this.comparator(item, this.items[mid]) < 0) {
246+
right = mid;
247+
} else {
248+
left = mid + 1;
249+
}
250+
}
251+
252+
return left;
253+
}
254+
255+
/**
256+
* Get the current list of items.
257+
*/
258+
getItems(): readonly T[] {
259+
return this.items;
260+
}
261+
262+
/**
263+
* Get an item by ID.
264+
*/
265+
getById(id: string): T | undefined {
266+
return this.itemsById.get(id);
267+
}
268+
269+
/**
270+
* Get the current count of items.
271+
*/
272+
getCount(): number {
273+
return this.items.length;
274+
}
275+
276+
/**
277+
* Clear all items from the list.
278+
*/
279+
clear(): void {
280+
this.items = [];
281+
this.itemsById.clear();
282+
}
283+
284+
/**
285+
* Initialize the list with an array of items.
286+
* This sorts and limits the items according to the configuration.
287+
*/
288+
initialize(items: T[]): void {
289+
this.clear();
290+
291+
// Sort if comparator exists
292+
let sortedItems = [...items];
293+
if (this.comparator) {
294+
sortedItems.sort(this.comparator);
295+
}
296+
297+
// Apply limit
298+
if (this.limit) {
299+
sortedItems = sortedItems.slice(0, this.limit);
300+
}
301+
302+
// Add to list and map
303+
this.items = sortedItems;
304+
for (const item of sortedItems) {
305+
this.itemsById.set(this.getId(item), item);
306+
}
307+
}
308+
}

0 commit comments

Comments
 (0)