live-cache 0.2.1 → 0.2.3

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/README.md CHANGED
@@ -14,6 +14,12 @@ A lightweight, type-safe client-side database library for JavaScript written in
14
14
  - ♻️ Pluggable invalidation strategies (timeouts, focus, websockets)
15
15
  - 🎨 Beautiful examples included
16
16
 
17
+ ## Examples
18
+
19
+ See the `examples/` folder for ready-to-run demos:
20
+ - `examples/react`: PokéAPI explorer built with controllers + `useController`
21
+ - `examples/vanilla-js`: Simple browser demo using the UMD build
22
+
17
23
  ## Installation
18
24
 
19
25
  ```bash
@@ -132,17 +138,29 @@ Use `Controller<T, Name>` for **server-backed** resources: it wraps a `Collectio
132
138
 
133
139
  `commit()` is the important part: it **publishes** the latest snapshot to subscribers and **persists** the snapshot using the configured `StorageManager`.
134
140
 
141
+ The `fetch(where?)` method can fetch all data or query-specific data based on the `where` parameter:
142
+
135
143
  ```ts
136
144
  import { Controller } from "live-cache";
137
145
 
138
146
  type User = { id: number; name: string };
139
147
 
140
148
  class UsersController extends Controller<User, "users"> {
141
- async fetchAll(): Promise<[User[], number]> {
142
- const res = await fetch("/api/users");
143
- if (!res.ok) throw new Error("Failed to fetch users");
144
- const data = (await res.json()) as User[];
145
- return [data, data.length];
149
+ async fetch(where?: string | Partial<User>): Promise<[User[], number]> {
150
+ // Fetch all users if no where clause
151
+ if (!where) {
152
+ const res = await fetch("/api/users");
153
+ if (!res.ok) throw new Error("Failed to fetch users");
154
+ const data = (await res.json()) as User[];
155
+ return [data, data.length];
156
+ }
157
+
158
+ // Fetch specific user by id or name
159
+ const id = typeof where === "string" ? where : where.id;
160
+ const res = await fetch(`/api/users/${id}`);
161
+ if (!res.ok) throw new Error("Failed to fetch user");
162
+ const data = (await res.json()) as User;
163
+ return [[data], 1];
146
164
  }
147
165
 
148
166
  /**
@@ -151,7 +169,7 @@ class UsersController extends Controller<User, "users"> {
151
169
  */
152
170
  invalidate() {
153
171
  this.abort();
154
- void this.refetch();
172
+ void this.update();
155
173
  }
156
174
 
157
175
  async renameUser(id: number, name: string) {
@@ -163,6 +181,68 @@ class UsersController extends Controller<User, "users"> {
163
181
  }
164
182
  ```
165
183
 
184
+ ### Real-world example: PokéAPI integration
185
+
186
+ Here's a complete example from the `examples/react` demo showing how to build controllers for a public API:
187
+
188
+ ```ts
189
+ import { Controller } from "live-cache";
190
+
191
+ const API_BASE = "https://pokeapi.co/api/v2";
192
+
193
+ // Controller for fetching the list of Pokémon
194
+ class PokemonListController extends Controller<{ name: string; url: string }, "pokemonList"> {
195
+ constructor(name, options) {
196
+ super(name, options);
197
+ this.limit = 24;
198
+ }
199
+
200
+ async fetch() {
201
+ this.abort();
202
+ const response = await fetch(`${API_BASE}/pokemon?limit=${this.limit}`, {
203
+ signal: this.abortController?.signal,
204
+ });
205
+ if (!response.ok) throw new Error(`GET /pokemon failed (${response.status})`);
206
+ const data = await response.json();
207
+ return [data.results ?? [], data.count ?? 0];
208
+ }
209
+
210
+ invalidate() {
211
+ this.abort();
212
+ void this.update();
213
+ }
214
+ }
215
+
216
+ // Controller for fetching individual Pokémon details
217
+ class PokemonDetailsController extends Controller<any, "pokemonDetails"> {
218
+ resolveQuery(where) {
219
+ if (!where) return null;
220
+ if (typeof where === "string") return where;
221
+ if (where.name) return String(where.name);
222
+ if (where.id !== undefined) return String(where.id);
223
+ return null;
224
+ }
225
+
226
+ async fetch(where) {
227
+ const query = this.resolveQuery(where);
228
+ if (!query) return [[], 0];
229
+
230
+ this.abort();
231
+ const response = await fetch(`${API_BASE}/pokemon/${query}`, {
232
+ signal: this.abortController?.signal,
233
+ });
234
+ if (!response.ok) throw new Error(`GET /pokemon/${query} failed (${response.status})`);
235
+ const data = await response.json();
236
+ return [[data], 1];
237
+ }
238
+
239
+ invalidate() {
240
+ this.abort();
241
+ void this.update(this.lastQuery);
242
+ }
243
+ }
244
+ ```
245
+
166
246
  ### Persistence (`StorageManager`)
167
247
 
168
248
  Controllers persist snapshots through a `StorageManager` (array-of-models, not a JSON string).
@@ -213,6 +293,8 @@ Use `ContextProvider` to provide an `ObjectStore`, `useRegister()` to register c
213
293
  `controller.invalidator.registerInvalidation()` on mount and
214
294
  `controller.invalidator.unregisterInvalidation()` on unmount.
215
295
 
296
+ ### Basic example
297
+
216
298
  ```tsx
217
299
  import React from "react";
218
300
  import { ContextProvider, useRegister, useController } from "live-cache";
@@ -251,6 +333,40 @@ export default function Root() {
251
333
  }
252
334
  ```
253
335
 
336
+ ### Query-based fetching example
337
+
338
+ You can pass a `where` clause to `useController()` to fetch specific data:
339
+
340
+ ```tsx
341
+ import { useController } from "live-cache";
342
+ import { useMemo } from "react";
343
+
344
+ function PokemonDetails({ query }) {
345
+ // Convert query string to where clause
346
+ const where = useMemo(() => ({ name: query }), [query]);
347
+
348
+ const { data, loading, error } = useController(
349
+ "pokemonDetails",
350
+ where,
351
+ { initialise: !!where }
352
+ );
353
+
354
+ const pokemon = data[0];
355
+ if (loading) return <div>Loading Pokémon…</div>;
356
+ if (error) return <div>Error: {String(error)}</div>;
357
+ if (!pokemon) return null;
358
+
359
+ return (
360
+ <div>
361
+ <h2>{pokemon.name}</h2>
362
+ <img src={pokemon.sprites.front_default} alt={pokemon.name} />
363
+ </div>
364
+ );
365
+ }
366
+ ```
367
+
368
+ See `examples/react` for a complete PokéAPI explorer implementation with multiple components using controllers.
369
+
254
370
  ## Cache invalidation recipes
255
371
 
256
372
  These show **framework-agnostic** controller patterns and a **React** wiring example for each.
@@ -46,19 +46,20 @@ export interface ControllerOptions<TVariable, TName extends string> {
46
46
  storageManager?: StorageManager<TVariable[]>;
47
47
  pageSize?: number;
48
48
  invalidator?: Invalidator<TVariable>;
49
- initialiseOnMount?: boolean;
50
49
  }
51
50
  export default class Controller<TVariable, TName extends string> {
52
51
  name: TName;
53
52
  collection: Collection<TVariable, TName>;
54
53
  protected subscribers: Set<(model: ModelType<TVariable>[]) => void>;
55
- protected storageManager: StorageManager<TVariable[]>;
54
+ storageManager: StorageManager<TVariable[]>;
56
55
  loading: boolean;
57
56
  error: unknown;
58
57
  total: number;
59
- pageSize: number;
58
+ page: number;
59
+ limit: number;
60
60
  abortController: AbortController | null;
61
61
  invalidator: Invalidator<TVariable>;
62
+ initialised: boolean;
62
63
  /**
63
64
  * Abort any in-flight work owned by this controller (typically network fetches).
64
65
  *
@@ -66,15 +67,18 @@ export default class Controller<TVariable, TName extends string> {
66
67
  * pass `this.abortController.signal` to the next request.
67
68
  */
68
69
  abort(): void;
69
- protected updateTotal(total: number): void;
70
- protected updatePageSize(pageSize: number): void;
70
+ updateTotal(total: number): void;
71
+ updatePage(page: number): void;
72
+ updateLimit(limit: number): void;
71
73
  /**
72
74
  * Fetch the complete dataset for this controller.
73
75
  *
74
76
  * Subclasses must implement this. Return `[rows, total]` where `total` is the
75
77
  * total number of rows available on the backend (useful for pagination).
76
78
  */
77
- fetchAll(): Promise<[TVariable[], number]>;
79
+ fetch(where?: string | Partial<TVariable>): Promise<[TVariable[], number]>;
80
+ nextPage(where?: string | Partial<TVariable>): Promise<void>;
81
+ previousPage(where?: string | Partial<TVariable>): Promise<void>;
78
82
  /**
79
83
  * Initialise (hydrate) the controller's collection.
80
84
  *
@@ -85,7 +89,7 @@ export default class Controller<TVariable, TName extends string> {
85
89
  *
86
90
  * A successful initialise ends with `commit()` so subscribers receive the latest snapshot.
87
91
  */
88
- initialise(): Promise<void>;
92
+ initialise(where?: string | Partial<TVariable>): Promise<void>;
89
93
  /**
90
94
  * Subscribe to controller updates.
91
95
  *
@@ -99,13 +103,13 @@ export default class Controller<TVariable, TName extends string> {
99
103
  * unsubscribe();
100
104
  * ```
101
105
  */
102
- publish(onChange: (data: ModelType<TVariable>[]) => void): () => boolean;
106
+ subscribe(onChange: (models: ModelType<TVariable>[]) => void): () => boolean;
103
107
  /**
104
108
  * Persist the latest snapshot and notify all subscribers.
105
109
  *
106
110
  * This is intentionally private: consumers should use `commit()` which computes the snapshot.
107
111
  */
108
- private subscribe;
112
+ private publish;
109
113
  /**
110
114
  * Publish + persist the current snapshot.
111
115
  *
@@ -119,7 +123,7 @@ export default class Controller<TVariable, TName extends string> {
119
123
  *
120
124
  * Subclasses typically use this inside `invalidate()`.
121
125
  */
122
- protected refetch(): Promise<void>;
126
+ update(where?: string | Partial<TVariable>): Promise<void>;
123
127
  /**
124
128
  * Invalidate the cache for this controller.
125
129
  *
@@ -144,5 +148,5 @@ export default class Controller<TVariable, TName extends string> {
144
148
  * @param storageManager - where snapshots are persisted (defaults to no-op)
145
149
  * @param pageSize - optional pagination hint (userland)
146
150
  */
147
- constructor(name: TName, { storageManager, pageSize, invalidator, initialiseOnMount, }: ControllerOptions<TVariable, TName>);
151
+ constructor(name: TName, { storageManager, pageSize, invalidator, }: ControllerOptions<TVariable, TName>);
148
152
  }
@@ -38,7 +38,7 @@ export default class ObjectStore {
38
38
  /**
39
39
  * Initialise a controller once per store, even if multiple callers request it.
40
40
  */
41
- initialiseOnce<TVariable, TName extends string>(name: TName): Promise<void>;
41
+ initialiseOnce<TVariable, TName extends string>(name: TName, where?: string | Partial<TVariable>): Promise<void>;
42
42
  }
43
43
  /**
44
44
  * Returns a singleton store instance.
package/dist/index.cjs CHANGED
@@ -529,14 +529,17 @@ class Controller {
529
529
  abort() {
530
530
  if (this.abortController) {
531
531
  this.abortController.abort();
532
+ this.abortController = null;
532
533
  }
533
- this.abortController = new AbortController();
534
534
  }
535
535
  updateTotal(total) {
536
536
  this.total = total;
537
537
  }
538
- updatePageSize(pageSize) {
539
- this.pageSize = pageSize;
538
+ updatePage(page) {
539
+ this.page = page;
540
+ }
541
+ updateLimit(limit) {
542
+ this.limit = limit;
540
543
  }
541
544
  /**
542
545
  * Fetch the complete dataset for this controller.
@@ -544,11 +547,23 @@ class Controller {
544
547
  * Subclasses must implement this. Return `[rows, total]` where `total` is the
545
548
  * total number of rows available on the backend (useful for pagination).
546
549
  */
547
- fetchAll() {
550
+ fetch(where) {
548
551
  return __awaiter(this, void 0, void 0, function* () {
549
552
  throw Error("Not Implemented");
550
553
  });
551
554
  }
555
+ nextPage(where) {
556
+ return __awaiter(this, void 0, void 0, function* () {
557
+ this.updatePage(this.page + 1);
558
+ yield this.update(where);
559
+ });
560
+ }
561
+ previousPage(where) {
562
+ return __awaiter(this, void 0, void 0, function* () {
563
+ this.updatePage(this.page - 1);
564
+ yield this.update(where);
565
+ });
566
+ }
552
567
  /**
553
568
  * Initialise (hydrate) the controller's collection.
554
569
  *
@@ -559,37 +574,41 @@ class Controller {
559
574
  *
560
575
  * A successful initialise ends with `commit()` so subscribers receive the latest snapshot.
561
576
  */
562
- initialise() {
577
+ initialise(where) {
563
578
  return __awaiter(this, void 0, void 0, function* () {
564
- var _a;
565
- if (this.loading)
566
- return;
579
+ this.abortController = new AbortController();
567
580
  // If the collection is not empty, return.
568
- let data = this.collection.find().map((doc) => doc.toData());
581
+ let data = this.collection.find(where).map((doc) => doc.toData());
569
582
  if (data.length !== 0) {
570
- return;
571
- }
572
- // If the collection is empty, check the storage manager.
573
- data = (_a = (yield this.storageManager.get(this.name))) !== null && _a !== void 0 ? _a : [];
574
- if (data.length !== 0) {
575
- this.updateTotal(this.collection.find().length);
576
- this.collection.insertMany(data);
583
+ this.updateTotal(data.length);
577
584
  yield this.commit();
578
585
  return;
579
586
  }
587
+ const fromStorage = yield this.storageManager.get(this.name);
588
+ if (fromStorage && fromStorage.length !== 0) {
589
+ const __collection = new Collection(this.name);
590
+ __collection.insertMany(fromStorage);
591
+ const __data = __collection.find(where).map(x => x.toData());
592
+ if (__data.length !== 0) {
593
+ this.collection.insertMany(__data);
594
+ this.updateTotal(__data.length);
595
+ yield this.commit();
596
+ return;
597
+ }
598
+ }
580
599
  // If the storage manager is empty, fetch the data from the server.
581
600
  try {
582
601
  this.loading = true;
583
- const [_data, total] = yield this.fetchAll();
602
+ const [_data, total] = yield this.fetch(where);
584
603
  this.collection.insertMany(_data);
585
604
  this.updateTotal(total);
586
- yield this.commit();
587
605
  }
588
606
  catch (error) {
589
607
  this.error = error;
590
608
  }
591
609
  finally {
592
610
  this.loading = false;
611
+ yield this.commit();
593
612
  }
594
613
  });
595
614
  }
@@ -606,7 +625,7 @@ class Controller {
606
625
  * unsubscribe();
607
626
  * ```
608
627
  */
609
- publish(onChange) {
628
+ subscribe(onChange) {
610
629
  this.subscribers.add(onChange);
611
630
  return () => this.subscribers.delete(onChange);
612
631
  }
@@ -615,12 +634,12 @@ class Controller {
615
634
  *
616
635
  * This is intentionally private: consumers should use `commit()` which computes the snapshot.
617
636
  */
618
- subscribe(model) {
637
+ publish(models) {
619
638
  return __awaiter(this, void 0, void 0, function* () {
620
639
  // Persist the full cache snapshot for hydration.
621
640
  yield this.storageManager.set(this.name, this.collection.find().map((doc) => doc.toModel()));
622
641
  this.subscribers.forEach((sub) => {
623
- sub(model);
642
+ sub(models);
624
643
  });
625
644
  });
626
645
  }
@@ -634,7 +653,7 @@ class Controller {
634
653
  commit() {
635
654
  return __awaiter(this, void 0, void 0, function* () {
636
655
  const models = this.collection.find().map((doc) => doc.toModel());
637
- yield this.subscribe(models);
656
+ yield this.publish(models);
638
657
  });
639
658
  }
640
659
  /**
@@ -642,8 +661,13 @@ class Controller {
642
661
  *
643
662
  * Subclasses typically use this inside `invalidate()`.
644
663
  */
645
- refetch() {
646
- return this.initialise();
664
+ update(where) {
665
+ return __awaiter(this, void 0, void 0, function* () {
666
+ const [response, total] = yield this.fetch(where);
667
+ this.collection.insertMany(response);
668
+ this.updateTotal(total);
669
+ yield this.commit();
670
+ });
647
671
  }
648
672
  /**
649
673
  * Invalidate the cache for this controller.
@@ -667,10 +691,11 @@ class Controller {
667
691
  void this.storageManager.delete(this.name);
668
692
  this.collection.clear();
669
693
  this.updateTotal(0);
670
- this.updatePageSize(-1);
694
+ this.updatePage(0);
695
+ this.updateLimit(10);
671
696
  this.error = null;
672
697
  this.loading = false;
673
- void this.subscribe([]);
698
+ void this.publish([]);
674
699
  }
675
700
  /**
676
701
  * Create a controller.
@@ -679,22 +704,22 @@ class Controller {
679
704
  * @param storageManager - where snapshots are persisted (defaults to no-op)
680
705
  * @param pageSize - optional pagination hint (userland)
681
706
  */
682
- constructor(name, { storageManager = new DefaultStorageManager("live-cache:"), pageSize = -1, invalidator = new DefaultInvalidator(), initialiseOnMount = true, }) {
707
+ constructor(name, { storageManager = new DefaultStorageManager("live-cache:"), pageSize = 10, invalidator = new DefaultInvalidator(), }) {
683
708
  this.subscribers = new Set();
684
709
  this.loading = false;
685
710
  this.error = null;
686
711
  this.total = -1;
687
- this.pageSize = -1;
712
+ this.page = 0;
713
+ this.limit = 10;
688
714
  this.abortController = null;
715
+ this.initialised = false;
689
716
  this.name = name;
690
717
  this.collection = new Collection(name);
691
718
  this.storageManager = storageManager;
692
- this.pageSize = pageSize;
719
+ this.page = 0;
720
+ this.limit = pageSize;
693
721
  this.invalidator = invalidator;
694
722
  this.invalidator.bind(this.invalidate.bind(this));
695
- if (initialiseOnMount) {
696
- this.initialise();
697
- }
698
723
  }
699
724
  }
700
725
 
@@ -947,12 +972,12 @@ class ObjectStore {
947
972
  /**
948
973
  * Initialise a controller once per store, even if multiple callers request it.
949
974
  */
950
- initialiseOnce(name) {
975
+ initialiseOnce(name, where) {
951
976
  const controller = this.get(name);
952
977
  const existing = this.initialisePromises.get(controller);
953
978
  if (existing)
954
979
  return existing;
955
- const promise = controller.initialise().finally(() => {
980
+ const promise = controller.initialise(where).finally(() => {
956
981
  if (this.initialisePromises.get(controller) === promise) {
957
982
  this.initialisePromises.delete(controller);
958
983
  }
@@ -1302,7 +1327,7 @@ function useRegister(controller, store = getDefaultObjectStore()) {
1302
1327
  */
1303
1328
  function useController(name, where, options) {
1304
1329
  var _a, _b, _c, _d;
1305
- const initialise = (_a = options === null || options === void 0 ? void 0 : options.initialise) !== null && _a !== void 0 ? _a : true;
1330
+ (_a = options === null || options === void 0 ? void 0 : options.initialise) !== null && _a !== void 0 ? _a : true;
1306
1331
  const optionalStore = options === null || options === void 0 ? void 0 : options.store;
1307
1332
  const abortOnUnmount = (_b = options === null || options === void 0 ? void 0 : options.abortOnUnmount) !== null && _b !== void 0 ? _b : true;
1308
1333
  const withInvalidation = (_c = options === null || options === void 0 ? void 0 : options.withInvalidation) !== null && _c !== void 0 ? _c : true;
@@ -1324,13 +1349,12 @@ function useController(name, where, options) {
1324
1349
  };
1325
1350
  // Prime state immediately.
1326
1351
  callback();
1327
- const cleanup = controller.publish(callback);
1352
+ const cleanup = controller.subscribe(callback);
1328
1353
  if (withInvalidation) {
1329
1354
  controller.invalidator.registerInvalidation();
1330
1355
  }
1331
- if (initialise) {
1332
- void store.initialiseOnce(name);
1333
- }
1356
+ void store.initialiseOnce(name, where);
1357
+ // controller.initialise(where);
1334
1358
  return () => {
1335
1359
  if (abortOnUnmount) {
1336
1360
  controller.abort();
@@ -1338,7 +1362,7 @@ function useController(name, where, options) {
1338
1362
  cleanup();
1339
1363
  controller.invalidator.unregisterInvalidation();
1340
1364
  };
1341
- }, [controller, where, initialise, abortOnUnmount, withInvalidation]);
1365
+ }, [controller, where, abortOnUnmount, withInvalidation]);
1342
1366
  return { controller, data, loading, error };
1343
1367
  }
1344
1368
 
@@ -1361,7 +1385,7 @@ function useJoinController({ from, where, select }) {
1361
1385
  setData(join(from, where, select));
1362
1386
  };
1363
1387
  callback();
1364
- const cleanup = from.map((c) => c.publish(callback));
1388
+ const cleanup = from.map((c) => c.subscribe(callback));
1365
1389
  return () => {
1366
1390
  cleanup.forEach((c) => c());
1367
1391
  };