live-cache 0.1.0 β†’ 0.2.0

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
@@ -10,6 +10,8 @@ A lightweight, type-safe client-side database library for JavaScript written in
10
10
  - ⚑ Fast indexed queries using hash-based lookups
11
11
  - πŸ’Ύ Built-in serialization/deserialization for persistence
12
12
  - πŸ” MongoDB-like query interface
13
+ - 🧰 Optimistic transactions with rollback support
14
+ - ♻️ Pluggable invalidation strategies (timeouts, focus, websockets)
13
15
  - 🎨 Beautiful examples included
14
16
 
15
17
  ## Installation
@@ -147,10 +149,9 @@ class UsersController extends Controller<User, "users"> {
147
149
  * Example invalidation hook (you decide what invalidation means).
148
150
  * Common behavior is: abort in-flight fetch, clear/patch local cache, refetch, then commit.
149
151
  */
150
- invalidate(): () => void {
152
+ invalidate() {
151
153
  this.abort();
152
154
  void this.refetch();
153
- return () => {};
154
155
  }
155
156
 
156
157
  async renameUser(id: number, name: string) {
@@ -169,16 +170,48 @@ Controllers persist snapshots through a `StorageManager` (array-of-models, not a
169
170
  ```ts
170
171
  import { Controller, LocalStorageStorageManager } from "live-cache";
171
172
 
172
- const users = new UsersController(
173
- "users",
174
- true,
175
- new LocalStorageStorageManager("my-app:")
176
- );
173
+ const users = new UsersController("users", {
174
+ storageManager: new LocalStorageStorageManager("my-app:"),
175
+ });
176
+ // Other options: pageSize, invalidator, initialiseOnMount
177
+ ```
178
+
179
+ ### Transactions (optimistic updates)
180
+
181
+ Transactions store collection snapshots so you can rollback failed mutations.
182
+
183
+ ```ts
184
+ import { Controller, Transactions, LocalStorageStorageManager } from "live-cache";
185
+
186
+ // Do this once at app startup.
187
+ Transactions.createInstance(new LocalStorageStorageManager("my-app:tx:"));
188
+
189
+ class UsersController extends Controller<User, "users"> {
190
+ async updateUser(id: number, patch: Partial<User>) {
191
+ const transaction = await Transactions.add(this.collection);
192
+ try {
193
+ this.collection.findOneAndUpdate({ id }, patch);
194
+ await this.commit();
195
+
196
+ // ...perform server request...
197
+
198
+ await Transactions.finish(this.name);
199
+ } catch (error) {
200
+ const previous = await Transactions.rollback(transaction, this.name);
201
+ this.collection.hydrate(previous.serialize());
202
+ await this.commit();
203
+ throw error;
204
+ }
205
+ }
206
+ }
177
207
  ```
178
208
 
179
209
  ## React integration
180
210
 
181
211
  Use `ContextProvider` to provide an `ObjectStore`, `useRegister()` to register controllers, and `useController()` to subscribe to a controller.
212
+ `useController()` automatically wires invalidators by calling
213
+ `controller.invalidator.registerInvalidation()` on mount and
214
+ `controller.invalidator.unregisterInvalidation()` on unmount.
182
215
 
183
216
  ```tsx
184
217
  import React from "react";
@@ -222,22 +255,32 @@ export default function Root() {
222
255
 
223
256
  These show **framework-agnostic** controller patterns and a **React** wiring example for each.
224
257
 
258
+ ### TODO: More invalidation strategies
259
+ - [ ] Manual trigger
260
+ - [ ] SWR on demand
261
+ - [ ] Polling with backoff
262
+ - [ ] ETag / If-Modified-Since
263
+ - [ ] Server-Sent Events (SSE)
264
+ - [ ] LRU-based invalidation
265
+ - [ ] Background sync
266
+
225
267
  ### 1) Timeout-based cache invalidation (TTL)
226
268
 
227
269
  #### Framework-agnostic
228
270
 
229
271
  ```ts
230
- import { Controller } from "live-cache";
272
+ import { Controller, TimeoutInvalidator } from "live-cache";
231
273
 
232
274
  type Post = { id: number; title: string };
233
275
 
234
276
  class PostsController extends Controller<Post, "posts"> {
235
277
  private ttlMs: number;
236
278
  private lastFetchedAt = 0;
237
- private cleanupInvalidation: (() => void) | null = null;
238
279
 
239
280
  constructor(name: "posts", ttlMs = 30_000) {
240
- super(name);
281
+ super(name, {
282
+ invalidator: new TimeoutInvalidator<Post>(ttlMs, { immediate: true }),
283
+ });
241
284
  this.ttlMs = ttlMs;
242
285
  }
243
286
 
@@ -249,31 +292,20 @@ class PostsController extends Controller<Post, "posts"> {
249
292
  }
250
293
 
251
294
  /**
252
- * TTL invalidation lives here:
253
- * - first call wires up the interval
254
- * - subsequent calls perform the TTL check and revalidate if expired
295
+ * TTL invalidation logic lives here (TimeoutInvalidator triggers this).
255
296
  */
256
- invalidate(): () => void {
257
- if (!this.cleanupInvalidation) {
258
- const id = window.setInterval(() => void this.invalidate(), this.ttlMs);
259
- this.cleanupInvalidation = () => {
260
- window.clearInterval(id);
261
- this.cleanupInvalidation = null;
262
- };
263
- }
264
-
297
+ invalidate() {
265
298
  const now = Date.now();
266
299
  const fresh = this.lastFetchedAt && now - this.lastFetchedAt < this.ttlMs;
267
- if (fresh) return this.cleanupInvalidation!;
300
+ if (fresh) return;
268
301
 
269
302
  this.abort();
270
303
  void this.refetch();
271
- return this.cleanupInvalidation!;
272
304
  }
273
305
  }
274
306
 
275
307
  const posts = new PostsController("posts", 10_000);
276
- posts.invalidate(); // starts the interval + performs initial TTL check
308
+ posts.invalidator.registerInvalidation(); // starts interval + initial check
277
309
  ```
278
310
 
279
311
  #### React
@@ -294,14 +326,33 @@ function PostsPage() {
294
326
  #### Framework-agnostic
295
327
 
296
328
  ```ts
297
- import { Controller } from "live-cache";
329
+ import { Controller, Invalidator } from "live-cache";
298
330
 
299
331
  type Todo = { id: number; title: string };
300
332
 
333
+ class SwrInvalidator<T> extends Invalidator<T> {
334
+ private revalidate = () => this.invalidator();
335
+
336
+ registerInvalidation() {
337
+ window.addEventListener("focus", this.revalidate);
338
+ window.addEventListener("online", this.revalidate);
339
+ }
340
+
341
+ unregisterInvalidation() {
342
+ window.removeEventListener("focus", this.revalidate);
343
+ window.removeEventListener("online", this.revalidate);
344
+ }
345
+ }
346
+
301
347
  class TodosController extends Controller<Todo, "todos"> {
302
348
  private revalidateAfterMs = 30_000;
303
349
  private lastFetchedAt = 0;
304
- private cleanupInvalidation: (() => void) | null = null;
350
+
351
+ constructor(name: "todos") {
352
+ super(name, {
353
+ invalidator: new SwrInvalidator<Todo>(),
354
+ });
355
+ }
305
356
 
306
357
  async fetchAll(): Promise<[Todo[], number]> {
307
358
  const res = await fetch("/api/todos");
@@ -321,29 +372,14 @@ class TodosController extends Controller<Todo, "todos"> {
321
372
  if (stale) void this.refetch();
322
373
  }
323
374
 
324
- invalidate(): () => void {
325
- // SWR-style invalidation wiring lives here:
326
- // - first call wires up triggers (focus/online)
327
- // - every call can also trigger a revalidation
328
- if (!this.cleanupInvalidation) {
329
- const revalidate = () => {
330
- this.abort();
331
- void this.refetch();
332
- };
333
- window.addEventListener("focus", revalidate);
334
- window.addEventListener("online", revalidate);
335
- this.cleanupInvalidation = () => {
336
- window.removeEventListener("focus", revalidate);
337
- window.removeEventListener("online", revalidate);
338
- this.cleanupInvalidation = null;
339
- };
340
- }
341
-
375
+ invalidate() {
342
376
  this.abort();
343
377
  void this.refetch();
344
- return this.cleanupInvalidation!;
345
378
  }
346
379
  }
380
+
381
+ const todos = new TodosController("todos");
382
+ todos.invalidator.registerInvalidation();
347
383
  ```
348
384
 
349
385
  #### React
@@ -372,6 +408,8 @@ function TodosPage() {
372
408
  #### Framework-agnostic
373
409
 
374
410
  ```ts
411
+ import { Controller, Invalidator } from "live-cache";
412
+
375
413
  type InvalidationMsg =
376
414
  | { type: "invalidate"; controller: "users" }
377
415
  | { type: "patch-user"; id: number; name: string };
@@ -380,9 +418,6 @@ class UsersController extends Controller<
380
418
  { id: number; name: string },
381
419
  "users"
382
420
  > {
383
- private ws: WebSocket | null = null;
384
- private cleanupInvalidation: (() => void) | null = null;
385
-
386
421
  async fetchAll() {
387
422
  const res = await fetch("/api/users");
388
423
  const data = (await res.json()) as { id: number; name: string }[];
@@ -390,38 +425,46 @@ class UsersController extends Controller<
390
425
  }
391
426
 
392
427
  /**
393
- * Websocket subscription lives here:
394
- * - first call attaches the socket + listeners
395
- * - incoming messages either trigger a refetch or apply a patch + commit
428
+ * Websocket message handling lives here (wiring lives in an Invalidator).
396
429
  */
397
- invalidate(): () => void {
398
- if (this.cleanupInvalidation) return this.cleanupInvalidation;
430
+ invalidate(msg?: InvalidationMsg) {
431
+ if (!msg) return;
432
+ if (msg.type === "invalidate" && msg.controller === "users") {
433
+ this.abort();
434
+ void this.refetch();
435
+ return;
436
+ }
399
437
 
438
+ if (msg.type === "patch-user") {
439
+ this.collection.findOneAndUpdate({ id: msg.id }, { name: msg.name });
440
+ void this.commit();
441
+ }
442
+ }
443
+ }
444
+
445
+ class WebsocketInvalidator extends Invalidator<InvalidationMsg> {
446
+ private ws: WebSocket | null = null;
447
+
448
+ registerInvalidation() {
400
449
  const ws = new WebSocket("wss://example.com/ws");
401
450
  this.ws = ws;
402
- this.cleanupInvalidation = () => {
403
- this.ws?.close();
404
- this.ws = null;
405
- this.cleanupInvalidation = null;
406
- };
407
451
 
408
452
  ws.addEventListener("message", (evt) => {
409
453
  const msg = JSON.parse(String(evt.data)) as InvalidationMsg;
410
-
411
- if (msg.type === "invalidate" && msg.controller === "users") {
412
- this.abort();
413
- void this.refetch();
414
- return;
415
- }
416
-
417
- if (msg.type === "patch-user") {
418
- this.collection.findOneAndUpdate({ id: msg.id }, { name: msg.name });
419
- void this.commit();
420
- }
454
+ this.invalidator(msg);
421
455
  });
422
- return this.cleanupInvalidation;
456
+ }
457
+
458
+ unregisterInvalidation() {
459
+ this.ws?.close();
460
+ this.ws = null;
423
461
  }
424
462
  }
463
+
464
+ const usersController = new UsersController("users", {
465
+ invalidator: new WebsocketInvalidator(),
466
+ });
467
+ usersController.invalidator.registerInvalidation();
425
468
  ```
426
469
 
427
470
  #### React
@@ -473,7 +516,8 @@ const rows = useJoinController({
473
516
 
474
517
  For full details, see the TSDoc on the exported APIs.
475
518
 
476
- - **Core**: `Collection`, `Document`, `Controller`, `ObjectStore`, `StorageManager`, `DefaultStorageManager`, `join`
519
+ - **Core**: `Collection`, `Document`, `Controller`, `ObjectStore`, `StorageManager`, `DefaultStorageManager`, `join`, `Transactions`
520
+ - **Invalidation**: `Invalidator`, `DefaultInvalidator`, `TimeoutInvalidator`
477
521
  - **Storage managers**: `LocalStorageStorageManager`, `IndexDbStorageManager`
478
522
  - **React**: `ContextProvider`, `useRegister`, `useController`, `useJoinController`
479
523
 
@@ -1,6 +1,7 @@
1
1
  import Collection from "./Collection";
2
2
  import { ModelType } from "./Document";
3
- import { DefaultStorageManager, StorageManager } from "./StorageManager";
3
+ import { Invalidator } from "./Invalidator";
4
+ import { StorageManager } from "./StorageManager";
4
5
  /**
5
6
  * Controller is the recommended integration layer for server-backed resources.
6
7
  *
@@ -41,16 +42,23 @@ import { DefaultStorageManager, StorageManager } from "./StorageManager";
41
42
  * }
42
43
  * ```
43
44
  */
45
+ export interface ControllerOptions<TVariable, TName extends string> {
46
+ storageManager?: StorageManager<TVariable[]>;
47
+ pageSize?: number;
48
+ invalidator?: Invalidator<TVariable>;
49
+ initialiseOnMount?: boolean;
50
+ }
44
51
  export default class Controller<TVariable, TName extends string> {
45
52
  name: TName;
46
53
  collection: Collection<TVariable, TName>;
47
54
  protected subscribers: Set<(model: ModelType<TVariable>[]) => void>;
48
- protected storageManager: StorageManager<TVariable>;
55
+ protected storageManager: StorageManager<TVariable[]>;
49
56
  loading: boolean;
50
57
  error: unknown;
51
58
  total: number;
52
59
  pageSize: number;
53
60
  abortController: AbortController | null;
61
+ invalidator: Invalidator<TVariable>;
54
62
  /**
55
63
  * Abort any in-flight work owned by this controller (typically network fetches).
56
64
  *
@@ -123,7 +131,7 @@ export default class Controller<TVariable, TName extends string> {
123
131
  * This method should return a cleanup function that unregisters any timers/listeners/sockets
124
132
  * created as part of invalidation wiring.
125
133
  */
126
- invalidate(...data: TVariable[]): () => void;
134
+ invalidate(...data: TVariable[]): void;
127
135
  /**
128
136
  * Clear in-memory cache and delete persisted snapshot.
129
137
  * Publishes an empty snapshot to subscribers.
@@ -133,9 +141,8 @@ export default class Controller<TVariable, TName extends string> {
133
141
  * Create a controller.
134
142
  *
135
143
  * @param name - stable controller/collection name
136
- * @param initialise - whether to run `initialise()` immediately
137
144
  * @param storageManager - where snapshots are persisted (defaults to no-op)
138
145
  * @param pageSize - optional pagination hint (userland)
139
146
  */
140
- constructor(name: TName, initialise?: boolean, storageManager?: DefaultStorageManager<TVariable>, pageSize?: number);
147
+ constructor(name: TName, { storageManager, pageSize, invalidator, initialiseOnMount, }: ControllerOptions<TVariable, TName>);
141
148
  }
@@ -0,0 +1,11 @@
1
+ export declare class Invalidator<TVariable> {
2
+ protected invalidator: (...data: TVariable[]) => void;
3
+ bind(invalidator: typeof this.invalidator): void;
4
+ registerInvalidation(): void;
5
+ unregisterInvalidation(): void;
6
+ }
7
+ export declare class DefaultInvalidator<TVariable> extends Invalidator<TVariable> {
8
+ bind(invalidator: (...data: TVariable[]) => void): void;
9
+ registerInvalidation(): void;
10
+ unregisterInvalidation(): () => void;
11
+ }
@@ -14,6 +14,7 @@ import Controller from "./Controller";
14
14
  */
15
15
  export default class ObjectStore {
16
16
  store: Map<string, Controller<any, any>>;
17
+ private initialisePromises;
17
18
  /**
18
19
  * Register a controller instance in this store.
19
20
  */
@@ -34,6 +35,10 @@ export default class ObjectStore {
34
35
  * This is equivalent to calling `controller.initialise()` for each controller.
35
36
  */
36
37
  initialise(): void;
38
+ /**
39
+ * Initialise a controller once per store, even if multiple callers request it.
40
+ */
41
+ initialiseOnce<TVariable, TName extends string>(name: TName): Promise<void>;
37
42
  }
38
43
  /**
39
44
  * Returns a singleton store instance.
@@ -1,4 +1,3 @@
1
- import { ModelType } from "./Document";
2
1
  /**
3
2
  * Storage adapter used by `Controller` to persist and hydrate snapshots.
4
3
  *
@@ -6,30 +5,35 @@ import { ModelType } from "./Document";
6
5
  * Implementations should be resilient: reads should return `[]` on failure.
7
6
  */
8
7
  export declare abstract class StorageManager<TVariable> {
8
+ prefix: string;
9
+ constructor(prefix: string);
9
10
  /**
10
11
  * Get a previously persisted snapshot for a controller name.
11
12
  *
12
13
  * @returns Array of models (each model includes `_id`)
13
14
  */
14
- abstract get<T>(name: string): Promise<ModelType<T>[]>;
15
+ abstract get(name: string): Promise<TVariable | null>;
15
16
  /**
16
17
  * Persist a snapshot for a controller name.
17
18
  *
18
19
  * Controllers call this from `commit()`.
19
20
  */
20
- abstract set<T>(name: string, models: ModelType<T>[]): Promise<void>;
21
+ abstract set(name: string, models: TVariable): Promise<void>;
21
22
  /**
22
23
  * Delete the persisted snapshot for a controller name.
23
24
  */
24
25
  abstract delete(name: string): Promise<void>;
26
+ abstract getParams(): Promise<string[]>;
25
27
  }
26
28
  /**
27
29
  * No-op storage manager.
28
30
  *
29
31
  * Useful in environments where you don’t want persistence (tests, ephemeral caches, etc).
30
32
  */
31
- export declare class DefaultStorageManager<TVariable> implements StorageManager<TVariable> {
32
- get<T>(_name: string): Promise<ModelType<T>[]>;
33
- set<T>(_name: string, _models: ModelType<T>[]): Promise<void>;
33
+ export declare class DefaultStorageManager<TVariable> extends StorageManager<TVariable> {
34
+ constructor(prefix: string);
35
+ get(name: string): Promise<TVariable | null>;
36
+ set(name: string, models: TVariable): Promise<void>;
34
37
  delete(_name: string): Promise<void>;
38
+ getParams(): Promise<string[]>;
35
39
  }
@@ -0,0 +1,20 @@
1
+ import Collection from "./Collection";
2
+ import { StorageManager } from "./StorageManager";
3
+ declare class TransactionsInstance<TVariable, TName extends string> {
4
+ private storageManager;
5
+ constructor(storageManager: StorageManager<string>);
6
+ add<TVariable, TName extends string>(collection: Collection<TVariable, TName>): Promise<string>;
7
+ rollback(transaction_name: string, name: TName): Promise<Collection<TVariable, TName>>;
8
+ finish(name: TName): Promise<void>;
9
+ get<TVariable, TName extends string>(transaction_name: string, name: TName): Promise<Collection<TVariable, TName>>;
10
+ }
11
+ export default class Transactions {
12
+ static instance: TransactionsInstance<any, any>;
13
+ static createInstance(storageManager: StorageManager<string>): void;
14
+ static getInstance(): TransactionsInstance<any, any>;
15
+ static add<TVariable, TName extends string>(collection: Collection<TVariable, TName>): Promise<string>;
16
+ static rollback<TVariable, TName extends string>(transaction_name: string, name: TName): Promise<Collection<TVariable, TName>>;
17
+ static finish(name: string): Promise<void>;
18
+ static get<TVariable, TName extends string>(transaction_name: string, name: TName): Promise<Collection<TVariable, TName>>;
19
+ }
20
+ export {};