live-cache 0.2.2 → 0.2.4

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.
package/dist/index.cjs CHANGED
@@ -1089,35 +1089,79 @@ class Transactions {
1089
1089
  */
1090
1090
  class IndexDbStorageManager extends StorageManager {
1091
1091
  constructor(options = {}) {
1092
- var _a, _b, _c, _d;
1092
+ var _a, _b, _c, _d, _e;
1093
1093
  super((_a = options.prefix) !== null && _a !== void 0 ? _a : "live-cache:");
1094
1094
  this.dbPromise = null;
1095
- this.dbName = (_b = options.dbName) !== null && _b !== void 0 ? _b : "live-cache";
1096
- this.storeName = (_c = options.storeName) !== null && _c !== void 0 ? _c : "collections";
1097
- this.prefix = (_d = options.prefix) !== null && _d !== void 0 ? _d : "live-cache:";
1095
+ this.dbPromises = new Map();
1096
+ this.storeName = (_b = options.storeName) !== null && _b !== void 0 ? _b : "collections";
1097
+ this.prefix = (_c = options.prefix) !== null && _c !== void 0 ? _c : "live-cache:";
1098
+ this.useSameDatabase = (_d = options.useSameDatabase) !== null && _d !== void 0 ? _d : false;
1099
+ this.dbName = (_e = options.dbName) !== null && _e !== void 0 ? _e : "live-cache";
1098
1100
  }
1099
1101
  key(name) {
1100
1102
  return `${this.prefix}${name}`;
1101
1103
  }
1102
- openDb() {
1103
- if (this.dbPromise)
1104
+ getDbName(name) {
1105
+ if (this.useSameDatabase) {
1106
+ return this.dbName;
1107
+ }
1108
+ else {
1109
+ // Create a separate database for each collection
1110
+ const collectionName = name !== null && name !== void 0 ? name : this.storeName;
1111
+ return `${this.dbName}-${collectionName}`;
1112
+ }
1113
+ }
1114
+ openDb(name) {
1115
+ if (this.useSameDatabase) {
1116
+ if (this.dbPromise)
1117
+ return this.dbPromise;
1118
+ this.dbPromise = new Promise((resolve, reject) => {
1119
+ if (typeof indexedDB === "undefined") {
1120
+ reject(new Error("indexedDB is not available in this environment"));
1121
+ return;
1122
+ }
1123
+ const dbName = this.getDbName();
1124
+ const request = indexedDB.open(dbName, 1);
1125
+ request.onupgradeneeded = () => {
1126
+ const db = request.result;
1127
+ if (!db.objectStoreNames.contains(this.storeName)) {
1128
+ db.createObjectStore(this.storeName);
1129
+ }
1130
+ };
1131
+ request.onsuccess = () => resolve(request.result);
1132
+ request.onerror = () => { var _a; return reject((_a = request.error) !== null && _a !== void 0 ? _a : new Error("Failed to open IndexedDB")); };
1133
+ });
1104
1134
  return this.dbPromise;
1105
- this.dbPromise = new Promise((resolve, reject) => {
1106
- if (typeof indexedDB === "undefined") {
1107
- reject(new Error("indexedDB is not available in this environment"));
1108
- return;
1135
+ }
1136
+ else {
1137
+ // Use separate database per collection
1138
+ if (!name) {
1139
+ throw new Error("Collection name is required when useSameDatabase is false");
1109
1140
  }
1110
- const request = indexedDB.open(this.dbName, 1);
1111
- request.onupgradeneeded = () => {
1112
- const db = request.result;
1113
- if (!db.objectStoreNames.contains(this.storeName)) {
1114
- db.createObjectStore(this.storeName);
1141
+ const existing = this.dbPromises.get(name);
1142
+ if (existing)
1143
+ return existing;
1144
+ const promise = new Promise((resolve, reject) => {
1145
+ if (typeof indexedDB === "undefined") {
1146
+ reject(new Error("indexedDB is not available in this environment"));
1147
+ return;
1115
1148
  }
1116
- };
1117
- request.onsuccess = () => resolve(request.result);
1118
- request.onerror = () => { var _a; return reject((_a = request.error) !== null && _a !== void 0 ? _a : new Error("Failed to open IndexedDB")); };
1119
- });
1120
- return this.dbPromise;
1149
+ const dbName = this.getDbName(name);
1150
+ const request = indexedDB.open(dbName, 1);
1151
+ request.onupgradeneeded = () => {
1152
+ const db = request.result;
1153
+ // When using separate databases, use a single object store named "documents"
1154
+ const storeName = "documents";
1155
+ if (!db.objectStoreNames.contains(storeName)) {
1156
+ db.createObjectStore(storeName);
1157
+ }
1158
+ };
1159
+ request.onsuccess = () => resolve(request.result);
1160
+ request.onerror = () => { var _a; return reject((_a = request.error) !== null && _a !== void 0 ? _a : new Error("Failed to open IndexedDB")); };
1161
+ });
1162
+ this.dbPromises.set(name, promise);
1163
+ return promise;
1164
+ }
1121
1165
  }
1122
1166
  idbGet(key) {
1123
1167
  return __awaiter(this, void 0, void 0, function* () {
@@ -1136,6 +1180,22 @@ class IndexDbStorageManager extends StorageManager {
1136
1180
  });
1137
1181
  });
1138
1182
  }
1183
+ idbGetAll(name) {
1184
+ return __awaiter(this, void 0, void 0, function* () {
1185
+ const db = yield this.openDb(name);
1186
+ const storeName = this.useSameDatabase ? this.storeName : "documents";
1187
+ return yield new Promise((resolve, reject) => {
1188
+ const tx = db.transaction(storeName, "readonly");
1189
+ const store = tx.objectStore(storeName);
1190
+ const req = store.getAll();
1191
+ req.onsuccess = () => {
1192
+ const values = req.result;
1193
+ resolve(Array.isArray(values) ? values : []);
1194
+ };
1195
+ req.onerror = () => { var _a; return reject((_a = req.error) !== null && _a !== void 0 ? _a : new Error("IndexedDB getAll failed")); };
1196
+ });
1197
+ });
1198
+ }
1139
1199
  idbSet(key, value) {
1140
1200
  return __awaiter(this, void 0, void 0, function* () {
1141
1201
  const db = yield this.openDb();
@@ -1149,6 +1209,27 @@ class IndexDbStorageManager extends StorageManager {
1149
1209
  });
1150
1210
  });
1151
1211
  }
1212
+ idbSetAll(name, models) {
1213
+ return __awaiter(this, void 0, void 0, function* () {
1214
+ const db = yield this.openDb(name);
1215
+ const storeName = this.useSameDatabase ? this.storeName : "documents";
1216
+ yield new Promise((resolve, reject) => {
1217
+ const tx = db.transaction(storeName, "readwrite");
1218
+ const store = tx.objectStore(storeName);
1219
+ // Clear existing documents first
1220
+ store.clear();
1221
+ // Store each document individually using _id as key
1222
+ for (const model of models) {
1223
+ if (model && model._id) {
1224
+ store.put(model, model._id);
1225
+ }
1226
+ }
1227
+ tx.oncomplete = () => resolve();
1228
+ tx.onerror = () => { var _a; return reject((_a = tx.error) !== null && _a !== void 0 ? _a : new Error("IndexedDB setAll failed")); };
1229
+ tx.onabort = () => { var _a; return reject((_a = tx.error) !== null && _a !== void 0 ? _a : new Error("IndexedDB setAll aborted")); };
1230
+ });
1231
+ });
1232
+ }
1152
1233
  idbDelete(key) {
1153
1234
  return __awaiter(this, void 0, void 0, function* () {
1154
1235
  const db = yield this.openDb();
@@ -1162,50 +1243,105 @@ class IndexDbStorageManager extends StorageManager {
1162
1243
  });
1163
1244
  });
1164
1245
  }
1246
+ idbDeleteAll(name) {
1247
+ return __awaiter(this, void 0, void 0, function* () {
1248
+ const db = yield this.openDb(name);
1249
+ const storeName = this.useSameDatabase ? this.storeName : "documents";
1250
+ yield new Promise((resolve, reject) => {
1251
+ const tx = db.transaction(storeName, "readwrite");
1252
+ const store = tx.objectStore(storeName);
1253
+ store.clear();
1254
+ tx.oncomplete = () => resolve();
1255
+ tx.onerror = () => { var _a; return reject((_a = tx.error) !== null && _a !== void 0 ? _a : new Error("IndexedDB deleteAll failed")); };
1256
+ tx.onabort = () => { var _a; return reject((_a = tx.error) !== null && _a !== void 0 ? _a : new Error("IndexedDB deleteAll aborted")); };
1257
+ });
1258
+ });
1259
+ }
1165
1260
  get(name) {
1166
1261
  return __awaiter(this, void 0, void 0, function* () {
1167
- const k = this.key(name);
1168
- try {
1169
- const value = yield this.idbGet(k);
1170
- return (value !== null && value !== void 0 ? value : []);
1262
+ if (this.useSameDatabase) {
1263
+ const k = this.key(name);
1264
+ try {
1265
+ const value = yield this.idbGet(k);
1266
+ return (value !== null && value !== void 0 ? value : []);
1267
+ }
1268
+ catch (_a) {
1269
+ return [];
1270
+ }
1171
1271
  }
1172
- catch (_a) {
1173
- return [];
1272
+ else {
1273
+ // When using separate databases, get all documents from the collection's database
1274
+ try {
1275
+ const values = yield this.idbGetAll(name);
1276
+ return values;
1277
+ }
1278
+ catch (_b) {
1279
+ return [];
1280
+ }
1174
1281
  }
1175
1282
  });
1176
1283
  }
1177
1284
  set(name, models) {
1178
1285
  return __awaiter(this, void 0, void 0, function* () {
1179
- const k = this.key(name);
1180
1286
  const value = Array.isArray(models) ? models : [];
1181
- try {
1182
- yield this.idbSet(k, value);
1287
+ if (this.useSameDatabase) {
1288
+ const k = this.key(name);
1289
+ try {
1290
+ yield this.idbSet(k, value);
1291
+ }
1292
+ catch (_a) {
1293
+ // ignore write errors
1294
+ }
1183
1295
  }
1184
- catch (_a) {
1185
- // ignore write errors
1296
+ else {
1297
+ // When using separate databases, store each document individually by _id
1298
+ try {
1299
+ yield this.idbSetAll(name, value);
1300
+ }
1301
+ catch (_b) {
1302
+ // ignore write errors
1303
+ }
1186
1304
  }
1187
1305
  });
1188
1306
  }
1189
1307
  delete(name) {
1190
1308
  return __awaiter(this, void 0, void 0, function* () {
1191
- const k = this.key(name);
1192
- try {
1193
- yield this.idbDelete(k);
1309
+ if (this.useSameDatabase) {
1310
+ const k = this.key(name);
1311
+ try {
1312
+ yield this.idbDelete(k);
1313
+ }
1314
+ catch (_a) {
1315
+ // ignore delete errors
1316
+ }
1194
1317
  }
1195
- catch (_a) {
1196
- // ignore delete errors
1318
+ else {
1319
+ // When using separate databases, clear all documents from the collection's database
1320
+ try {
1321
+ yield this.idbDeleteAll(name);
1322
+ }
1323
+ catch (_b) {
1324
+ // ignore delete errors
1325
+ }
1197
1326
  }
1198
1327
  });
1199
1328
  }
1200
1329
  getParams() {
1201
1330
  return __awaiter(this, void 0, void 0, function* () {
1202
- const db = yield this.openDb();
1203
- return new Promise((resolve, reject) => {
1204
- const req = db.transaction(this.storeName, "readonly").objectStore(this.storeName).getAllKeys();
1205
- const keys = req.result.map(x => x.toString().replace(this.prefix, ""));
1206
- req.onsuccess = () => resolve(keys);
1207
- req.onerror = () => { var _a; return reject((_a = req.error) !== null && _a !== void 0 ? _a : new Error("IndexedDB get params failed")); };
1208
- });
1331
+ if (this.useSameDatabase) {
1332
+ const db = yield this.openDb();
1333
+ return new Promise((resolve, reject) => {
1334
+ const req = db.transaction(this.storeName, "readonly").objectStore(this.storeName).getAllKeys();
1335
+ const keys = req.result.map(x => x.toString().replace(this.prefix, ""));
1336
+ req.onsuccess = () => resolve(keys);
1337
+ req.onerror = () => { var _a; return reject((_a = req.error) !== null && _a !== void 0 ? _a : new Error("IndexedDB get params failed")); };
1338
+ });
1339
+ }
1340
+ else {
1341
+ // When using separate databases, return the list of database names (collections)
1342
+ // This is a simplified implementation - in practice, you might want to track collections differently
1343
+ return Array.from(this.dbPromises.keys());
1344
+ }
1209
1345
  });
1210
1346
  }
1211
1347
  }
@@ -1326,16 +1462,14 @@ function useRegister(controller, store = getDefaultObjectStore()) {
1326
1462
  * ```
1327
1463
  */
1328
1464
  function useController(name, where, options) {
1329
- var _a, _b, _c, _d;
1330
- (_a = options === null || options === void 0 ? void 0 : options.initialise) !== null && _a !== void 0 ? _a : true;
1465
+ var _a, _b;
1331
1466
  const optionalStore = options === null || options === void 0 ? void 0 : options.store;
1332
- const abortOnUnmount = (_b = options === null || options === void 0 ? void 0 : options.abortOnUnmount) !== null && _b !== void 0 ? _b : true;
1333
- const withInvalidation = (_c = options === null || options === void 0 ? void 0 : options.withInvalidation) !== null && _c !== void 0 ? _c : true;
1467
+ const withInvalidation = (_a = options === null || options === void 0 ? void 0 : options.withInvalidation) !== null && _a !== void 0 ? _a : true;
1334
1468
  const [data, setData] = React.useState([]);
1335
1469
  const [loading, setLoading] = React.useState(false);
1336
1470
  const [error, setError] = React.useState(null);
1337
1471
  const defaultStore = React.useContext(context);
1338
- const store = (_d = optionalStore !== null && optionalStore !== void 0 ? optionalStore : defaultStore) !== null && _d !== void 0 ? _d : null;
1472
+ const store = (_b = optionalStore !== null && optionalStore !== void 0 ? optionalStore : defaultStore) !== null && _b !== void 0 ? _b : null;
1339
1473
  if (!store) {
1340
1474
  throw Error("Store is not defined");
1341
1475
  }
@@ -1354,15 +1488,12 @@ function useController(name, where, options) {
1354
1488
  controller.invalidator.registerInvalidation();
1355
1489
  }
1356
1490
  void store.initialiseOnce(name, where);
1357
- // controller.initialise(where);
1358
1491
  return () => {
1359
- if (abortOnUnmount) {
1360
- controller.abort();
1361
- }
1492
+ controller.abort();
1362
1493
  cleanup();
1363
1494
  controller.invalidator.unregisterInvalidation();
1364
1495
  };
1365
- }, [controller, where, abortOnUnmount, withInvalidation]);
1496
+ }, [where, withInvalidation]);
1366
1497
  return { controller, data, loading, error };
1367
1498
  }
1368
1499