live-cache 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,490 @@
1
+ # LiveCache
2
+
3
+ A lightweight, type-safe client-side database library for JavaScript written in TypeScript. Store and query data collections directly in the browser with MongoDB-like syntax.
4
+
5
+ ## Features
6
+
7
+ - 📦 Written in TypeScript with full type definitions
8
+ - 🎯 Small bundle size with minimal dependencies
9
+ - 🔧 Works in both browser and module environments
10
+ - ⚡ Fast indexed queries using hash-based lookups
11
+ - 💾 Built-in serialization/deserialization for persistence
12
+ - 🔍 MongoDB-like query interface
13
+ - 🎨 Beautiful examples included
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install live-cache
19
+ ```
20
+
21
+ Or use it directly in the browser via UMD build:
22
+
23
+ ```html
24
+ <script src="path/to/dist/index.umd.js"></script>
25
+ <script>
26
+ const { Collection } = LiveCache;
27
+ const users = new Collection("users");
28
+ </script>
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ### ES Modules (Recommended)
34
+
35
+ ```javascript
36
+ import { Collection } from "live-cache";
37
+
38
+ // Create a collection
39
+ const users = new Collection("users");
40
+
41
+ // Insert documents
42
+ const user = users.insertOne({
43
+ name: "John Doe",
44
+ email: "john@example.com",
45
+ age: 30,
46
+ });
47
+
48
+ console.log(user._id); // Auto-generated MongoDB-style ID
49
+
50
+ // Insert multiple documents
51
+ const newUsers = users.insertMany([
52
+ { name: "Jane Smith", email: "jane@example.com", age: 25 },
53
+ { name: "Bob Johnson", email: "bob@example.com", age: 35 },
54
+ ]);
55
+
56
+ // Find documents
57
+ const allUsers = users.find(); // Get all documents
58
+ const jane = users.findOne({ name: "Jane Smith" }); // Find by condition
59
+ const userById = users.findOne("507f1f77bcf86cd799439011"); // Find by _id
60
+
61
+ // Update documents
62
+ const updated = users.findOneAndUpdate({ name: "John Doe" }, { age: 31 });
63
+
64
+ // Delete documents
65
+ users.deleteOne({ name: "Bob Johnson" });
66
+ users.deleteOne("507f1f77bcf86cd799439011"); // Delete by _id
67
+ ```
68
+
69
+ ### Persistence with Serialization
70
+
71
+ ```javascript
72
+ import { Collection } from "live-cache";
73
+
74
+ const todos = new Collection("todos");
75
+
76
+ // Add some data
77
+ todos.insertMany([
78
+ { task: "Buy groceries", completed: false },
79
+ { task: "Write code", completed: true },
80
+ ]);
81
+
82
+ // Serialize to string for storage
83
+ const serialized = todos.serialize();
84
+ localStorage.setItem("todos", serialized);
85
+
86
+ // Later... deserialize and restore
87
+ const savedData = localStorage.getItem("todos");
88
+ const restoredTodos = Collection.deserialize("todos", savedData);
89
+
90
+ // Or hydrate an existing collection
91
+ todos.hydrate(savedData);
92
+ ```
93
+
94
+ ### Browser (UMD)
95
+
96
+ ```html
97
+ <script src="node_modules/live-cache/dist/index.umd.js"></script>
98
+ <script>
99
+ const { Collection } = LiveCache;
100
+
101
+ const products = new Collection("products");
102
+ products.insertOne({
103
+ name: "Laptop",
104
+ price: 999,
105
+ inStock: true,
106
+ });
107
+
108
+ const laptop = products.findOne({ name: "Laptop" });
109
+ console.log(laptop.toModel());
110
+ </script>
111
+ ```
112
+
113
+ ### Using ObjectStore (recommended with Controllers)
114
+
115
+ `ObjectStore` is a simple registry for controllers. It’s used by the React helpers, but you can use it in any framework.
116
+
117
+ ```ts
118
+ import { createObjectStore } from "live-cache";
119
+
120
+ const store = createObjectStore();
121
+ // store.register(new UsersController(...))
122
+ // store.get("users")
123
+ ```
124
+
125
+ ## Controllers (recommended integration layer)
126
+
127
+ Use `Controller<T, Name>` for **server-backed** resources: it wraps a `Collection` and adds hydration, persistence, subscriptions, and invalidation hooks.
128
+
129
+ ### Extending `Controller` + using `commit()`
130
+
131
+ `commit()` is the important part: it **publishes** the latest snapshot to subscribers and **persists** the snapshot using the configured `StorageManager`.
132
+
133
+ ```ts
134
+ import { Controller } from "live-cache";
135
+
136
+ type User = { id: number; name: string };
137
+
138
+ class UsersController extends Controller<User, "users"> {
139
+ async fetchAll(): Promise<[User[], number]> {
140
+ const res = await fetch("/api/users");
141
+ if (!res.ok) throw new Error("Failed to fetch users");
142
+ const data = (await res.json()) as User[];
143
+ return [data, data.length];
144
+ }
145
+
146
+ /**
147
+ * Example invalidation hook (you decide what invalidation means).
148
+ * Common behavior is: abort in-flight fetch, clear/patch local cache, refetch, then commit.
149
+ */
150
+ invalidate(): () => void {
151
+ this.abort();
152
+ void this.refetch();
153
+ return () => {};
154
+ }
155
+
156
+ async renameUser(id: number, name: string) {
157
+ // Mutate the collection…
158
+ this.collection.findOneAndUpdate({ id }, { name });
159
+ // …then commit so subscribers + persistence stay in sync.
160
+ await this.commit();
161
+ }
162
+ }
163
+ ```
164
+
165
+ ### Persistence (`StorageManager`)
166
+
167
+ Controllers persist snapshots through a `StorageManager` (array-of-models, not a JSON string).
168
+
169
+ ```ts
170
+ import { Controller, LocalStorageStorageManager } from "live-cache";
171
+
172
+ const users = new UsersController(
173
+ "users",
174
+ true,
175
+ new LocalStorageStorageManager("my-app:")
176
+ );
177
+ ```
178
+
179
+ ## React integration
180
+
181
+ Use `ContextProvider` to provide an `ObjectStore`, `useRegister()` to register controllers, and `useController()` to subscribe to a controller.
182
+
183
+ ```tsx
184
+ import React from "react";
185
+ import { ContextProvider, useRegister, useController } from "live-cache";
186
+
187
+ const usersController = new UsersController("users");
188
+
189
+ function App() {
190
+ useRegister([usersController]);
191
+ const { data, loading, error, controller } = useController(
192
+ "users",
193
+ undefined,
194
+ {
195
+ withInvalidation: true,
196
+ }
197
+ );
198
+
199
+ if (loading) return <div>Loading…</div>;
200
+ if (error) return <div>Something went wrong</div>;
201
+
202
+ return (
203
+ <div>
204
+ <button onClick={() => void controller.invalidate()}>Refresh</button>
205
+ {data.map((u) => (
206
+ <div key={u._id}>{u.name}</div>
207
+ ))}
208
+ </div>
209
+ );
210
+ }
211
+
212
+ export default function Root() {
213
+ return (
214
+ <ContextProvider>
215
+ <App />
216
+ </ContextProvider>
217
+ );
218
+ }
219
+ ```
220
+
221
+ ## Cache invalidation recipes
222
+
223
+ These show **framework-agnostic** controller patterns and a **React** wiring example for each.
224
+
225
+ ### 1) Timeout-based cache invalidation (TTL)
226
+
227
+ #### Framework-agnostic
228
+
229
+ ```ts
230
+ import { Controller } from "live-cache";
231
+
232
+ type Post = { id: number; title: string };
233
+
234
+ class PostsController extends Controller<Post, "posts"> {
235
+ private ttlMs: number;
236
+ private lastFetchedAt = 0;
237
+ private cleanupInvalidation: (() => void) | null = null;
238
+
239
+ constructor(name: "posts", ttlMs = 30_000) {
240
+ super(name);
241
+ this.ttlMs = ttlMs;
242
+ }
243
+
244
+ async fetchAll(): Promise<[Post[], number]> {
245
+ const res = await fetch("/api/posts");
246
+ const data = (await res.json()) as Post[];
247
+ this.lastFetchedAt = Date.now();
248
+ return [data, data.length];
249
+ }
250
+
251
+ /**
252
+ * TTL invalidation lives here:
253
+ * - first call wires up the interval
254
+ * - subsequent calls perform the TTL check and revalidate if expired
255
+ */
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
+
265
+ const now = Date.now();
266
+ const fresh = this.lastFetchedAt && now - this.lastFetchedAt < this.ttlMs;
267
+ if (fresh) return this.cleanupInvalidation!;
268
+
269
+ this.abort();
270
+ void this.refetch();
271
+ return this.cleanupInvalidation!;
272
+ }
273
+ }
274
+
275
+ const posts = new PostsController("posts", 10_000);
276
+ posts.invalidate(); // starts the interval + performs initial TTL check
277
+ ```
278
+
279
+ #### React
280
+
281
+ ```tsx
282
+ function PostsPage() {
283
+ useRegister([posts]);
284
+ const { data } = useController("posts", undefined, {
285
+ withInvalidation: true,
286
+ });
287
+
288
+ return data.map((p) => <div key={p._id}>{p.title}</div>);
289
+ }
290
+ ```
291
+
292
+ ### 2) SWR-style invalidation (stale-while-revalidate)
293
+
294
+ #### Framework-agnostic
295
+
296
+ ```ts
297
+ import { Controller } from "live-cache";
298
+
299
+ type Todo = { id: number; title: string };
300
+
301
+ class TodosController extends Controller<Todo, "todos"> {
302
+ private revalidateAfterMs = 30_000;
303
+ private lastFetchedAt = 0;
304
+ private cleanupInvalidation: (() => void) | null = null;
305
+
306
+ async fetchAll(): Promise<[Todo[], number]> {
307
+ const res = await fetch("/api/todos");
308
+ const data = (await res.json()) as Todo[];
309
+ this.lastFetchedAt = Date.now();
310
+ return [data, data.length];
311
+ }
312
+
313
+ async initialise(): Promise<void> {
314
+ // hydrate/publish cached snapshot first (super.initialise does this)
315
+ await super.initialise();
316
+
317
+ // then revalidate in background if stale
318
+ const stale =
319
+ !this.lastFetchedAt ||
320
+ Date.now() - this.lastFetchedAt > this.revalidateAfterMs;
321
+ if (stale) void this.refetch();
322
+ }
323
+
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
+
342
+ this.abort();
343
+ void this.refetch();
344
+ return this.cleanupInvalidation!;
345
+ }
346
+ }
347
+ ```
348
+
349
+ #### React
350
+
351
+ ```tsx
352
+ function TodosPage() {
353
+ useRegister([todos]);
354
+ const { data, loading, controller } = useController("todos", undefined, {
355
+ withInvalidation: true,
356
+ });
357
+
358
+ return (
359
+ <div>
360
+ {loading ? <div>Revalidating…</div> : null}
361
+ <button onClick={() => void controller.invalidate()}>Revalidate</button>
362
+ {data.map((t) => (
363
+ <div key={t._id}>{t.title}</div>
364
+ ))}
365
+ </div>
366
+ );
367
+ }
368
+ ```
369
+
370
+ ### 3) Websocket-based invalidation (push)
371
+
372
+ #### Framework-agnostic
373
+
374
+ ```ts
375
+ type InvalidationMsg =
376
+ | { type: "invalidate"; controller: "users" }
377
+ | { type: "patch-user"; id: number; name: string };
378
+
379
+ class UsersController extends Controller<
380
+ { id: number; name: string },
381
+ "users"
382
+ > {
383
+ private ws: WebSocket | null = null;
384
+ private cleanupInvalidation: (() => void) | null = null;
385
+
386
+ async fetchAll() {
387
+ const res = await fetch("/api/users");
388
+ const data = (await res.json()) as { id: number; name: string }[];
389
+ return [data, data.length] as const;
390
+ }
391
+
392
+ /**
393
+ * Websocket subscription lives here:
394
+ * - first call attaches the socket + listeners
395
+ * - incoming messages either trigger a refetch or apply a patch + commit
396
+ */
397
+ invalidate(): () => void {
398
+ if (this.cleanupInvalidation) return this.cleanupInvalidation;
399
+
400
+ const ws = new WebSocket("wss://example.com/ws");
401
+ this.ws = ws;
402
+ this.cleanupInvalidation = () => {
403
+ this.ws?.close();
404
+ this.ws = null;
405
+ this.cleanupInvalidation = null;
406
+ };
407
+
408
+ ws.addEventListener("message", (evt) => {
409
+ 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
+ }
421
+ });
422
+ return this.cleanupInvalidation;
423
+ }
424
+ }
425
+ ```
426
+
427
+ #### React
428
+
429
+ ```tsx
430
+ function UsersPage() {
431
+ useRegister([usersController]);
432
+ const { data, controller } = useController("users", undefined, {
433
+ withInvalidation: true,
434
+ });
435
+
436
+ return data.map((u) => <div key={u._id}>{u.name}</div>);
437
+ }
438
+ ```
439
+
440
+ ## Joins (advanced)
441
+
442
+ Join data across controllers with `join(from, where, select)` or subscribe in React via `useJoinController`.
443
+
444
+ ```ts
445
+ import { join } from "live-cache";
446
+
447
+ const result = join(
448
+ [usersController, postsController] as const,
449
+ {
450
+ $and: {
451
+ posts: { userId: { $ref: { controller: "users", field: "id" } } },
452
+ },
453
+ } as const,
454
+ ["users.name", "posts.title"] as const
455
+ );
456
+ ```
457
+
458
+ ```tsx
459
+ import { useJoinController } from "live-cache";
460
+
461
+ const rows = useJoinController({
462
+ from: [usersController, postsController] as const,
463
+ where: {
464
+ $and: {
465
+ posts: { userId: { $ref: { controller: "users", field: "id" } } },
466
+ },
467
+ } as const,
468
+ select: ["users.name", "posts.title"] as const,
469
+ });
470
+ ```
471
+
472
+ ## API Reference (high-level)
473
+
474
+ For full details, see the TSDoc on the exported APIs.
475
+
476
+ - **Core**: `Collection`, `Document`, `Controller`, `ObjectStore`, `StorageManager`, `DefaultStorageManager`, `join`
477
+ - **Storage managers**: `LocalStorageStorageManager`, `IndexDbStorageManager`
478
+ - **React**: `ContextProvider`, `useRegister`, `useController`, `useJoinController`
479
+
480
+ ## Development
481
+
482
+ ```bash
483
+ npm install
484
+ npm run build
485
+ npm run dev
486
+ ```
487
+
488
+ ## License
489
+
490
+ MIT
@@ -0,0 +1,92 @@
1
+ import Document from "./Document";
2
+ /**
3
+ * An in-memory collection of documents with simple hash-based indexes.
4
+ *
5
+ * - Insert/update/delete operations keep indexes consistent.
6
+ * - `find()` / `findOne()` attempt indexed lookups first and fall back to linear scan.
7
+ *
8
+ * This is commonly used via `Controller.collection`.
9
+ *
10
+ * @typeParam TVariable - the data shape stored in the collection (without `_id`)
11
+ * @typeParam TName - the collection name (string-literal type)
12
+ */
13
+ export default class Collection<TVariable, TName extends string> {
14
+ name: TName;
15
+ private dataMap;
16
+ private indexes;
17
+ private counter;
18
+ constructor(name: TName);
19
+ /**
20
+ * Clear all in-memory documents and indexes.
21
+ */
22
+ clear(): void;
23
+ /**
24
+ * Add a document to all relevant indexes
25
+ */
26
+ private addToIndexes;
27
+ /**
28
+ * Remove a document from all indexes
29
+ */
30
+ private removeFromIndexes;
31
+ /**
32
+ * Find a single document by _id or by matching conditions (optimized with indexes)
33
+ */
34
+ findOne(where: string | Partial<TVariable>): Document<TVariable> | null;
35
+ /**
36
+ * Find all documents matching the conditions (optimized with indexes)
37
+ */
38
+ find(where?: string | Partial<TVariable>): Document<TVariable>[];
39
+ /**
40
+ * Helper method to check if a document matches the conditions
41
+ */
42
+ private matchesConditions;
43
+ /**
44
+ * Insert a new document into the collection
45
+ */
46
+ insertOne(data: TVariable): Document<TVariable>;
47
+ /**
48
+ * Delete a document by _id or by matching conditions
49
+ */
50
+ deleteOne(where: string | Partial<TVariable>): boolean;
51
+ /**
52
+ * Find a document and update it with new data
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * users.findOneAndUpdate({ id: 1 }, { name: "Updated" });
57
+ * ```
58
+ */
59
+ findOneAndUpdate(where: string | Partial<TVariable>, update: Partial<TVariable>): Document<TVariable> | null;
60
+ /**
61
+ * Insert multiple documents into the collection at once
62
+ * @param dataArray Array of data objects to insert
63
+ * @returns Array of inserted documents
64
+ */
65
+ insertMany(dataArray: TVariable[]): Document<TVariable>[];
66
+ /**
67
+ * Serialize the collection to a plain object for storage
68
+ * @returns A plain object representation of the collection
69
+ */
70
+ serialize(): string;
71
+ /**
72
+ * Deserialize and restore collection data from storage
73
+ * @param serializedData The serialized collection data
74
+ * @returns A new Collection instance with the restored data
75
+ */
76
+ static deserialize<T, N extends string>(name: N, serializedData: string): Collection<T, N>;
77
+ /**
78
+ * Hydrate the current collection instance with data from storage
79
+ * This clears existing data and replaces it with the deserialized data
80
+ * @param serializedData The serialized collection data
81
+ */
82
+ hydrate(serializedData: string): void;
83
+ /**
84
+ * Dehydrate the collection to a format suitable for storage
85
+ * This is an alias for serialize() for semantic clarity
86
+ * @returns A serialized string representation of the collection
87
+ */
88
+ dehydrate(): string;
89
+ static hash(data: any): string;
90
+ private static stableStringify;
91
+ private static fnv1a;
92
+ }