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.
@@ -0,0 +1,1274 @@
1
+ (function (global, factory) {
2
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('react')) :
3
+ typeof define === 'function' && define.amd ? define(['exports', 'react'], factory) :
4
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.LiveCache = {}, global.React));
5
+ })(this, (function (exports, React) { 'use strict';
6
+
7
+ /******************************************************************************
8
+ Copyright (c) Microsoft Corporation.
9
+
10
+ Permission to use, copy, modify, and/or distribute this software for any
11
+ purpose with or without fee is hereby granted.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
14
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
15
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
16
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
17
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
18
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
19
+ PERFORMANCE OF THIS SOFTWARE.
20
+ ***************************************************************************** */
21
+ /* global Reflect, Promise, SuppressedError, Symbol, Iterator */
22
+
23
+
24
+ function __rest(s, e) {
25
+ var t = {};
26
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
27
+ t[p] = s[p];
28
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
29
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
30
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
31
+ t[p[i]] = s[p[i]];
32
+ }
33
+ return t;
34
+ }
35
+
36
+ function __awaiter(thisArg, _arguments, P, generator) {
37
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
38
+ return new (P || (P = Promise))(function (resolve, reject) {
39
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
40
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
41
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
42
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
43
+ });
44
+ }
45
+
46
+ typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
47
+ var e = new Error(message);
48
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
49
+ };
50
+
51
+ /*
52
+ * A Single Model stores the data for a single item.
53
+ */
54
+ /**
55
+ * A single stored record.
56
+ *
57
+ * `Document<T>` wraps raw data and adds:
58
+ * - a generated `_id` (MongoDB-style ObjectId)
59
+ * - `updatedAt` timestamp, refreshed on updates
60
+ *
61
+ * Use `toModel()` when you need a plain object including `_id`
62
+ * (this is what `Controller` publishes and persists).
63
+ *
64
+ * @typeParam TVariable - data shape without `_id`
65
+ */
66
+ class Document {
67
+ constructor(data, counter = 0 * 0xffffff) {
68
+ this._id = Document.generateId(counter);
69
+ this.data = data;
70
+ this.updatedAt = Date.now();
71
+ }
72
+ updateData(data) {
73
+ this.data = Object.assign(Object.assign({}, this.data), data);
74
+ this.updatedAt = Date.now();
75
+ }
76
+ /**
77
+ * Convert to a plain model including `_id`.
78
+ */
79
+ toModel() {
80
+ return Object.assign({ _id: this._id }, this.data);
81
+ }
82
+ /**
83
+ * Convert to raw data (without `_id`).
84
+ */
85
+ toData() {
86
+ return Object.assign({}, this.data);
87
+ }
88
+ /**
89
+ * Generate a MongoDB-style ObjectId (24 hex characters).
90
+ *
91
+ * Format: timestamp (4 bytes) + random process id (5 bytes) + counter (3 bytes)
92
+ */
93
+ static generateId(_counter) {
94
+ // MongoDB ObjectId structure (12 bytes = 24 hex chars):
95
+ // - 4 bytes: timestamp (seconds since Unix epoch)
96
+ // - 5 bytes: random process identifier
97
+ // - 3 bytes: incrementing counter
98
+ // 1. Timestamp (4 bytes) - seconds since Unix epoch
99
+ const timestamp = Math.floor(Date.now() / 1000);
100
+ // 2. Process identifier (5 bytes) - random value
101
+ const processId = this.processId;
102
+ // 3. Counter (3 bytes) - incrementing counter with wraparound
103
+ const counter = (_counter + 1) % 0xffffff;
104
+ // Convert to hexadecimal strings with proper padding
105
+ const timestampHex = timestamp.toString(16).padStart(8, "0");
106
+ const processIdHex = processId.toString(16).padStart(10, "0");
107
+ const counterHex = counter.toString(16).padStart(6, "0");
108
+ // Combine into 24-character hex string
109
+ return timestampHex + processIdHex + counterHex;
110
+ }
111
+ }
112
+ // Random value generated once per process (5 bytes)
113
+ Document.processId = Math.floor(Math.random() * 0xffffffffff);
114
+
115
+ /*
116
+ * A List Model stores a list of items.
117
+ */
118
+ /**
119
+ * An in-memory collection of documents with simple hash-based indexes.
120
+ *
121
+ * - Insert/update/delete operations keep indexes consistent.
122
+ * - `find()` / `findOne()` attempt indexed lookups first and fall back to linear scan.
123
+ *
124
+ * This is commonly used via `Controller.collection`.
125
+ *
126
+ * @typeParam TVariable - the data shape stored in the collection (without `_id`)
127
+ * @typeParam TName - the collection name (string-literal type)
128
+ */
129
+ class Collection {
130
+ constructor(name) {
131
+ this.name = name;
132
+ // hash of conditions mapping to _id
133
+ this.dataMap = {};
134
+ this.indexes = {};
135
+ this.counter = 0;
136
+ }
137
+ /**
138
+ * Clear all in-memory documents and indexes.
139
+ */
140
+ clear() {
141
+ this.dataMap = {};
142
+ this.indexes = {};
143
+ this.counter = 0;
144
+ }
145
+ /**
146
+ * Add a document to all relevant indexes
147
+ */
148
+ addToIndexes(doc) {
149
+ const model = doc.toModel();
150
+ // Create index entries for each property
151
+ for (const [key, value] of Object.entries(model)) {
152
+ if (key === "_id")
153
+ continue; // Skip _id as it's the primary key
154
+ const indexKey = Collection.hash({ [key]: value });
155
+ if (!this.indexes[indexKey]) {
156
+ this.indexes[indexKey] = [];
157
+ }
158
+ // Add document _id to index if not already present
159
+ if (!this.indexes[indexKey].includes(doc._id)) {
160
+ this.indexes[indexKey].push(doc._id);
161
+ }
162
+ }
163
+ // Also create a compound index for the full document conditions
164
+ const fullIndexKey = Collection.hash(model);
165
+ if (!this.indexes[fullIndexKey]) {
166
+ this.indexes[fullIndexKey] = [];
167
+ }
168
+ if (!this.indexes[fullIndexKey].includes(doc._id)) {
169
+ this.indexes[fullIndexKey].push(doc._id);
170
+ }
171
+ }
172
+ /**
173
+ * Remove a document from all indexes
174
+ */
175
+ removeFromIndexes(doc) {
176
+ const model = doc.toModel();
177
+ // Remove from all property indexes
178
+ for (const [key, value] of Object.entries(model)) {
179
+ if (key === "_id")
180
+ continue;
181
+ const indexKey = Collection.hash({ [key]: value });
182
+ if (this.indexes[indexKey]) {
183
+ this.indexes[indexKey] = this.indexes[indexKey].filter((id) => id !== doc._id);
184
+ // Clean up empty indexes
185
+ if (this.indexes[indexKey].length === 0) {
186
+ delete this.indexes[indexKey];
187
+ }
188
+ }
189
+ }
190
+ // Remove from compound index
191
+ const fullIndexKey = Collection.hash(model);
192
+ if (this.indexes[fullIndexKey]) {
193
+ this.indexes[fullIndexKey] = this.indexes[fullIndexKey].filter((id) => id !== doc._id);
194
+ if (this.indexes[fullIndexKey].length === 0) {
195
+ delete this.indexes[fullIndexKey];
196
+ }
197
+ }
198
+ }
199
+ /**
200
+ * Find a single document by _id or by matching conditions (optimized with indexes)
201
+ */
202
+ findOne(where) {
203
+ var _a;
204
+ if (typeof where === "string") {
205
+ return this.dataMap[where] || null;
206
+ }
207
+ // Try to use index for faster lookup
208
+ const indexKey = Collection.hash(where);
209
+ if (this.indexes[indexKey] && this.indexes[indexKey].length > 0) {
210
+ const docId = this.indexes[indexKey][0];
211
+ const doc = this.dataMap[docId];
212
+ // Verify the document still matches (in case of hash collision)
213
+ if (doc && this.matchesConditions(doc, where)) {
214
+ return doc;
215
+ }
216
+ }
217
+ // Fallback to linear search if index lookup fails
218
+ return ((_a = Object.values(this.dataMap).find((doc) => this.matchesConditions(doc, where))) !== null && _a !== void 0 ? _a : null);
219
+ }
220
+ /**
221
+ * Find all documents matching the conditions (optimized with indexes)
222
+ */
223
+ find(where) {
224
+ // If no conditions, return all documents
225
+ if (!where || Object.keys(where).length === 0) {
226
+ return Object.values(this.dataMap);
227
+ }
228
+ if (typeof where === "string") {
229
+ const doc = this.dataMap[where];
230
+ return doc ? [doc] : [];
231
+ }
232
+ // Try to use index for faster lookup
233
+ const indexKey = Collection.hash(where);
234
+ if (this.indexes[indexKey]) {
235
+ // Get candidate documents from index
236
+ const candidateDocs = this.indexes[indexKey]
237
+ .map((id) => this.dataMap[id])
238
+ .filter((doc) => doc && this.matchesConditions(doc, where));
239
+ if (candidateDocs.length > 0) {
240
+ return candidateDocs;
241
+ }
242
+ }
243
+ // Fallback to linear search
244
+ return Object.values(this.dataMap).filter((doc) => this.matchesConditions(doc, where));
245
+ }
246
+ /**
247
+ * Helper method to check if a document matches the conditions
248
+ */
249
+ matchesConditions(doc, where) {
250
+ const model = doc.toModel();
251
+ return Object.entries(where).every(([key, value]) => {
252
+ if (!(key in model))
253
+ return false;
254
+ return (Collection.hash(model[key]) ===
255
+ Collection.hash(value));
256
+ });
257
+ }
258
+ /**
259
+ * Insert a new document into the collection
260
+ */
261
+ insertOne(data) {
262
+ const doc = new Document(data, this.counter++);
263
+ this.dataMap[doc._id] = doc;
264
+ // Add to indexes
265
+ this.addToIndexes(doc);
266
+ return doc;
267
+ }
268
+ /**
269
+ * Delete a document by _id or by matching conditions
270
+ */
271
+ deleteOne(where) {
272
+ const doc = this.findOne(where);
273
+ if (!doc) {
274
+ return false;
275
+ }
276
+ // Remove from indexes first
277
+ this.removeFromIndexes(doc);
278
+ // Remove from dataMap
279
+ delete this.dataMap[doc._id];
280
+ return true;
281
+ }
282
+ /**
283
+ * Find a document and update it with new data
284
+ *
285
+ * @example
286
+ * ```ts
287
+ * users.findOneAndUpdate({ id: 1 }, { name: "Updated" });
288
+ * ```
289
+ */
290
+ findOneAndUpdate(where, update) {
291
+ const doc = this.findOne(where);
292
+ if (!update)
293
+ return doc;
294
+ if (!doc) {
295
+ // If document not found, insert a new one with the provided update data
296
+ const newDoc = this.insertOne(update);
297
+ this.addToIndexes(newDoc);
298
+ return newDoc;
299
+ }
300
+ // Keep indexes consistent: remove old index entries, update, then re-index
301
+ this.removeFromIndexes(doc);
302
+ doc.updateData(update);
303
+ this.addToIndexes(doc);
304
+ return doc;
305
+ }
306
+ /**
307
+ * Insert multiple documents into the collection at once
308
+ * @param dataArray Array of data objects to insert
309
+ * @returns Array of inserted documents
310
+ */
311
+ insertMany(dataArray) {
312
+ const insertedDocs = [];
313
+ for (const data of dataArray) {
314
+ const doc = new Document(data, this.counter++);
315
+ this.dataMap[doc._id] = doc;
316
+ // Add to indexes
317
+ this.addToIndexes(doc);
318
+ insertedDocs.push(doc);
319
+ }
320
+ return insertedDocs;
321
+ }
322
+ /**
323
+ * Serialize the collection to a plain object for storage
324
+ * @returns A plain object representation of the collection
325
+ */
326
+ serialize() {
327
+ const data = {
328
+ counter: this.counter,
329
+ documents: Object.values(this.dataMap).map((doc) => doc.toModel()),
330
+ };
331
+ return JSON.stringify(data);
332
+ }
333
+ /**
334
+ * Deserialize and restore collection data from storage
335
+ * @param serializedData The serialized collection data
336
+ * @returns A new Collection instance with the restored data
337
+ */
338
+ static deserialize(name, serializedData) {
339
+ const collection = new Collection(name);
340
+ try {
341
+ const data = JSON.parse(serializedData);
342
+ // Restore counter
343
+ collection.counter = data.counter || 0;
344
+ // Restore documents
345
+ if (data.documents && Array.isArray(data.documents)) {
346
+ for (let i = 0; i < data.documents.length; i++) {
347
+ const docData = data.documents[i];
348
+ const { _id } = docData, rest = __rest(docData, ["_id"]);
349
+ // Create document without counter increment. Internal _id is generated.
350
+ const doc = new Document(rest, i);
351
+ // Add to dataMap using the generated internal id
352
+ collection.dataMap[doc._id] = doc;
353
+ // Rebuild indexes for this document
354
+ collection.addToIndexes(doc);
355
+ }
356
+ }
357
+ }
358
+ catch (error) {
359
+ console.error("Failed to deserialize collection data:", error);
360
+ }
361
+ return collection;
362
+ }
363
+ /**
364
+ * Hydrate the current collection instance with data from storage
365
+ * This clears existing data and replaces it with the deserialized data
366
+ * @param serializedData The serialized collection data
367
+ */
368
+ hydrate(serializedData) {
369
+ try {
370
+ const data = JSON.parse(serializedData);
371
+ // Clear existing data
372
+ this.dataMap = {};
373
+ this.indexes = {};
374
+ this.counter = data.counter || 0;
375
+ // Restore documents
376
+ if (data.documents && Array.isArray(data.documents)) {
377
+ for (let i = 0; i < data.documents.length; i++) {
378
+ const docData = data.documents[i];
379
+ const { _id } = docData, rest = __rest(docData, ["_id"]);
380
+ // Create document without counter increment. Internal _id is generated.
381
+ const doc = new Document(rest, i);
382
+ // Add to dataMap using the generated internal id
383
+ this.dataMap[_id] = doc;
384
+ // Rebuild indexes for this document
385
+ this.addToIndexes(doc);
386
+ }
387
+ }
388
+ }
389
+ catch (error) {
390
+ console.error("Failed to hydrate collection:", error);
391
+ }
392
+ }
393
+ /**
394
+ * Dehydrate the collection to a format suitable for storage
395
+ * This is an alias for serialize() for semantic clarity
396
+ * @returns A serialized string representation of the collection
397
+ */
398
+ dehydrate() {
399
+ return this.serialize();
400
+ }
401
+ static hash(data) {
402
+ // Browser-safe hashing: stable stringify + FNV-1a (32-bit).
403
+ return Collection.fnv1a(Collection.stableStringify(data));
404
+ }
405
+ static stableStringify(value) {
406
+ const type = typeof value;
407
+ if (value === null)
408
+ return "null";
409
+ if (type === "string")
410
+ return JSON.stringify(value);
411
+ if (type === "number") {
412
+ if (Number.isFinite(value))
413
+ return String(value);
414
+ return JSON.stringify(String(value));
415
+ }
416
+ if (type === "boolean")
417
+ return value ? "true" : "false";
418
+ if (type === "undefined")
419
+ return "undefined";
420
+ if (type === "bigint")
421
+ return JSON.stringify(`bigint:${value.toString()}`);
422
+ if (type === "symbol")
423
+ return JSON.stringify(`symbol:${String(value)}`);
424
+ if (type === "function")
425
+ return JSON.stringify("function");
426
+ if (Array.isArray(value)) {
427
+ return ("[" + value.map((v) => Collection.stableStringify(v)).join(",") + "]");
428
+ }
429
+ if (value instanceof Date) {
430
+ return '{"$date":' + JSON.stringify(value.toISOString()) + "}";
431
+ }
432
+ // Plain object: sort keys for determinism
433
+ const keys = Object.keys(value).sort();
434
+ return ("{" +
435
+ keys
436
+ .map((k) => JSON.stringify(k) + ":" + Collection.stableStringify(value[k]))
437
+ .join(",") +
438
+ "}");
439
+ }
440
+ static fnv1a(input) {
441
+ // FNV-1a 32-bit
442
+ let hash = 0x811c9dc5;
443
+ for (let i = 0; i < input.length; i++) {
444
+ hash ^= input.charCodeAt(i);
445
+ hash = Math.imul(hash, 0x01000193);
446
+ }
447
+ // Unsigned + fixed-width hex to keep index keys uniform
448
+ return (hash >>> 0).toString(16).padStart(8, "0");
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Storage adapter used by `Controller` to persist and hydrate snapshots.
454
+ *
455
+ * A controller stores a **full snapshot** (array of models) keyed by `name`.
456
+ * Implementations should be resilient: reads should return `[]` on failure.
457
+ */
458
+ class StorageManager {
459
+ }
460
+ /**
461
+ * No-op storage manager.
462
+ *
463
+ * Useful in environments where you don’t want persistence (tests, ephemeral caches, etc).
464
+ */
465
+ class DefaultStorageManager {
466
+ get(_name) {
467
+ return __awaiter(this, void 0, void 0, function* () {
468
+ return Promise.resolve([]);
469
+ });
470
+ }
471
+ set(_name, _models) {
472
+ return __awaiter(this, void 0, void 0, function* () {
473
+ return Promise.resolve();
474
+ });
475
+ }
476
+ delete(_name) {
477
+ return __awaiter(this, void 0, void 0, function* () {
478
+ return Promise.resolve();
479
+ });
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Controller is the recommended integration layer for server-backed resources.
485
+ *
486
+ * It wraps a `Collection` with:
487
+ * - hydration (`initialise()`)
488
+ * - persistence (`commit()` writes a full snapshot using the configured `StorageManager`)
489
+ * - subscriptions (`publish()`)
490
+ * - invalidation hooks (`invalidate()`, `refetch()`)
491
+ *
492
+ * The intended mutation pattern is:
493
+ * 1) mutate `this.collection` (insert/update/delete)
494
+ * 2) call `await this.commit()` so subscribers update and storage persists
495
+ *
496
+ * @typeParam TVariable - the “data” shape stored in the collection (without `_id`)
497
+ * @typeParam TName - a stable, string-literal name for this controller/collection
498
+ *
499
+ * @example
500
+ * ```ts
501
+ * type User = { id: number; name: string };
502
+ *
503
+ * class UsersController extends Controller<User, "users"> {
504
+ * async fetchAll(): Promise<[User[], number]> {
505
+ * const res = await fetch("/api/users");
506
+ * const data = (await res.json()) as User[];
507
+ * return [data, data.length];
508
+ * }
509
+ *
510
+ * invalidate(): () => void {
511
+ * this.abort();
512
+ * void this.refetch();
513
+ * return () => {};
514
+ * }
515
+ *
516
+ * async renameUser(id: number, name: string) {
517
+ * this.collection.findOneAndUpdate({ id }, { name });
518
+ * await this.commit();
519
+ * }
520
+ * }
521
+ * ```
522
+ */
523
+ class Controller {
524
+ /**
525
+ * Abort any in-flight work owned by this controller (typically network fetches).
526
+ *
527
+ * This method also installs a new `AbortController` so subclasses can safely
528
+ * pass `this.abortController.signal` to the next request.
529
+ */
530
+ abort() {
531
+ if (this.abortController) {
532
+ this.abortController.abort();
533
+ }
534
+ this.abortController = new AbortController();
535
+ }
536
+ updateTotal(total) {
537
+ this.total = total;
538
+ }
539
+ updatePageSize(pageSize) {
540
+ this.pageSize = pageSize;
541
+ }
542
+ /**
543
+ * Fetch the complete dataset for this controller.
544
+ *
545
+ * Subclasses must implement this. Return `[rows, total]` where `total` is the
546
+ * total number of rows available on the backend (useful for pagination).
547
+ */
548
+ fetchAll() {
549
+ return __awaiter(this, void 0, void 0, function* () {
550
+ throw Error("Not Implemented");
551
+ });
552
+ }
553
+ /**
554
+ * Initialise (hydrate) the controller's collection.
555
+ *
556
+ * Resolution order:
557
+ * 1) If the in-memory collection is already non-empty: do nothing.
558
+ * 2) Else, try `storageManager.get(name)` and hydrate from persisted snapshot.
559
+ * 3) Else, call `fetchAll()` and populate from the backend.
560
+ *
561
+ * A successful initialise ends with `commit()` so subscribers receive the latest snapshot.
562
+ */
563
+ initialise() {
564
+ return __awaiter(this, void 0, void 0, function* () {
565
+ // If the collection is not empty, return.
566
+ let data = this.collection.find().map((doc) => doc.toData());
567
+ if (data.length !== 0)
568
+ return;
569
+ // If the collection is empty, check the storage manager.
570
+ data = yield this.storageManager.get(this.name);
571
+ if (data.length !== 0) {
572
+ this.updateTotal(this.collection.find().length);
573
+ this.collection.insertMany(data);
574
+ yield this.commit();
575
+ return;
576
+ }
577
+ // If the storage manager is empty, fetch the data from the server.
578
+ try {
579
+ this.loading = true;
580
+ const [_data, total] = yield this.fetchAll();
581
+ this.collection.insertMany(_data);
582
+ this.updateTotal(total);
583
+ }
584
+ catch (error) {
585
+ this.error = error;
586
+ }
587
+ finally {
588
+ this.loading = false;
589
+ }
590
+ yield this.commit();
591
+ });
592
+ }
593
+ /**
594
+ * Subscribe to controller updates.
595
+ *
596
+ * The callback receives the full snapshot (`ModelType<TVariable>[]`) each time `commit()` runs.
597
+ * Returns an unsubscribe function.
598
+ *
599
+ * @example
600
+ * ```ts
601
+ * const unsubscribe = controller.publish((rows) => console.log(rows.length));
602
+ * // later...
603
+ * unsubscribe();
604
+ * ```
605
+ */
606
+ publish(onChange) {
607
+ this.subscribers.add(onChange);
608
+ return () => this.subscribers.delete(onChange);
609
+ }
610
+ /**
611
+ * Persist the latest snapshot and notify all subscribers.
612
+ *
613
+ * This is intentionally private: consumers should use `commit()` which computes the snapshot.
614
+ */
615
+ subscribe(model) {
616
+ return __awaiter(this, void 0, void 0, function* () {
617
+ // Persist the full cache snapshot for hydration.
618
+ yield this.storageManager.set(this.name, this.collection.find().map((doc) => doc.toModel()));
619
+ this.subscribers.forEach((sub) => {
620
+ sub(model);
621
+ });
622
+ });
623
+ }
624
+ /**
625
+ * Publish + persist the current snapshot.
626
+ *
627
+ * Call this after any local mutation of `this.collection` so:
628
+ * - subscribers are updated (UI refresh)
629
+ * - the `StorageManager` has the latest snapshot for future hydration
630
+ */
631
+ commit() {
632
+ return __awaiter(this, void 0, void 0, function* () {
633
+ const models = this.collection.find().map((doc) => doc.toModel());
634
+ yield this.subscribe(models);
635
+ });
636
+ }
637
+ /**
638
+ * Refetch data using the controller's initialise flow.
639
+ *
640
+ * Subclasses typically use this inside `invalidate()`.
641
+ */
642
+ refetch() {
643
+ return this.initialise();
644
+ }
645
+ /**
646
+ * Invalidate the cache for this controller.
647
+ *
648
+ * Subclasses must implement this. Common patterns:
649
+ * - TTL based: refetch when expired
650
+ * - SWR: revalidate in background
651
+ * - push: refetch or patch based on websocket messages
652
+ *
653
+ * This method should return a cleanup function that unregisters any timers/listeners/sockets
654
+ * created as part of invalidation wiring.
655
+ */
656
+ invalidate(...data) {
657
+ throw Error("Not Implemented");
658
+ }
659
+ /**
660
+ * Clear in-memory cache and delete persisted snapshot.
661
+ * Publishes an empty snapshot to subscribers.
662
+ */
663
+ reset() {
664
+ void this.storageManager.delete(this.name);
665
+ this.collection.clear();
666
+ this.updateTotal(0);
667
+ this.updatePageSize(-1);
668
+ this.error = null;
669
+ this.loading = false;
670
+ void this.subscribe([]);
671
+ }
672
+ /**
673
+ * Create a controller.
674
+ *
675
+ * @param name - stable controller/collection name
676
+ * @param initialise - whether to run `initialise()` immediately
677
+ * @param storageManager - where snapshots are persisted (defaults to no-op)
678
+ * @param pageSize - optional pagination hint (userland)
679
+ */
680
+ constructor(name, initialise = true, storageManager = new DefaultStorageManager(), pageSize = -1) {
681
+ this.subscribers = new Set();
682
+ this.loading = false;
683
+ this.error = null;
684
+ this.total = -1;
685
+ this.pageSize = -1;
686
+ this.abortController = null;
687
+ this.collection = new Collection(name);
688
+ this.storageManager = storageManager;
689
+ this.name = name;
690
+ this.pageSize = pageSize;
691
+ if (initialise) {
692
+ void this.initialise();
693
+ }
694
+ }
695
+ }
696
+
697
+ function cartesian(arrays) {
698
+ if (arrays.length === 0)
699
+ return [[]];
700
+ for (const a of arrays)
701
+ if (a.length === 0)
702
+ return [];
703
+ let acc = [[]];
704
+ for (const items of arrays) {
705
+ const next = [];
706
+ for (const prefix of acc)
707
+ for (const item of items)
708
+ next.push([...prefix, item]);
709
+ acc = next;
710
+ }
711
+ return acc;
712
+ }
713
+ function hasEq(value) {
714
+ return typeof value === "object" && value !== null && "$eq" in value;
715
+ }
716
+ function hasRef(value) {
717
+ return (typeof value === "object" &&
718
+ value !== null &&
719
+ "$ref" in value &&
720
+ typeof value.$ref === "object" &&
721
+ value.$ref !== null &&
722
+ "controller" in value.$ref &&
723
+ "field" in value.$ref);
724
+ }
725
+ function isJoinRefObject(value) {
726
+ return (typeof value === "object" &&
727
+ value !== null &&
728
+ "controller" in value &&
729
+ "field" in value);
730
+ }
731
+ function buildPrefilterObject(where) {
732
+ // Only include conditions that Collection can evaluate locally:
733
+ // - literal values
734
+ // - {$eq: literal}
735
+ // Excludes cross-controller comparisons ($ref, $eq with JoinRef).
736
+ const out = {};
737
+ for (const [k, v] of Object.entries(where)) {
738
+ if (hasRef(v))
739
+ continue;
740
+ if (hasEq(v)) {
741
+ const rhs = v.$eq;
742
+ if (isJoinRefObject(rhs))
743
+ continue;
744
+ out[k] = rhs;
745
+ continue;
746
+ }
747
+ out[k] = v;
748
+ }
749
+ return out;
750
+ }
751
+ function getByName(comboByName, controller, field) {
752
+ var _a;
753
+ return (_a = comboByName[controller]) === null || _a === void 0 ? void 0 : _a[field];
754
+ }
755
+ function applyAliasesToModel(model, rawWhere, comboByName) {
756
+ for (const [field, cond] of Object.entries(rawWhere)) {
757
+ const as = cond === null || cond === void 0 ? void 0 : cond.$as;
758
+ if (!as)
759
+ continue;
760
+ // Prefer the local value; if missing, try resolving from ref/eq(rhs ref).
761
+ let value = model[field];
762
+ if (value === undefined) {
763
+ if (hasRef(cond)) {
764
+ value = getByName(comboByName, cond.$ref.controller, cond.$ref.field);
765
+ }
766
+ else if (hasEq(cond) && isJoinRefObject(cond.$eq)) {
767
+ const rhs = cond.$eq;
768
+ value = getByName(comboByName, rhs.controller, rhs.field);
769
+ }
770
+ }
771
+ model[as] = value;
772
+ }
773
+ }
774
+ function join(from, where = {}, select) {
775
+ var _a;
776
+ const andWhere = ((_a = where.$and) !== null && _a !== void 0 ? _a : {});
777
+ const controllerNames = from.map((c) => c.name);
778
+ const perControllerMatches = from.map((c) => {
779
+ var _a;
780
+ const rawWhere = ((_a = andWhere[c.name]) !== null && _a !== void 0 ? _a : {});
781
+ const prefilter = buildPrefilterObject(rawWhere);
782
+ return c.collection.find(prefilter).map((d) => d.toModel());
783
+ });
784
+ const combos = cartesian(perControllerMatches);
785
+ const filtered = combos.filter((models) => {
786
+ var _a, _b, _c;
787
+ const byName = {};
788
+ for (let i = 0; i < controllerNames.length; i++)
789
+ byName[controllerNames[i]] = (_a = models[i]) !== null && _a !== void 0 ? _a : {};
790
+ for (const c of from) {
791
+ const rawWhere = ((_b = andWhere[c.name]) !== null && _b !== void 0 ? _b : {});
792
+ const model = (_c = byName[c.name]) !== null && _c !== void 0 ? _c : {};
793
+ for (const [field, cond] of Object.entries(rawWhere)) {
794
+ if (hasRef(cond)) {
795
+ const lhs = model[field];
796
+ const rhs = getByName(byName, cond.$ref.controller, cond.$ref.field);
797
+ if (lhs !== rhs)
798
+ return false;
799
+ continue;
800
+ }
801
+ if (hasEq(cond)) {
802
+ const lhs = model[field];
803
+ const rhs = cond.$eq;
804
+ if (isJoinRefObject(rhs)) {
805
+ const rhsVal = getByName(byName, rhs.controller, rhs.field);
806
+ if (lhs !== rhsVal)
807
+ return false;
808
+ }
809
+ else {
810
+ if (lhs !== rhs)
811
+ return false;
812
+ }
813
+ }
814
+ }
815
+ }
816
+ return true;
817
+ });
818
+ if (!select) {
819
+ return filtered.map((ms) => Object.assign({}, ...ms));
820
+ }
821
+ return filtered.map((ms) => {
822
+ var _a, _b, _c;
823
+ // Rebuild per-controller view to apply $as aliases deterministically.
824
+ const byName = {};
825
+ for (let i = 0; i < controllerNames.length; i++)
826
+ byName[controllerNames[i]] = Object.assign({}, ((_a = ms[i]) !== null && _a !== void 0 ? _a : {}));
827
+ for (const c of from) {
828
+ const rawWhere = ((_b = andWhere[c.name]) !== null && _b !== void 0 ? _b : {});
829
+ applyAliasesToModel((_c = byName[c.name]) !== null && _c !== void 0 ? _c : {}, rawWhere, byName);
830
+ }
831
+ // Now project.
832
+ const out = {};
833
+ const getQualified = (qualified) => {
834
+ var _a;
835
+ const dot = qualified.indexOf(".");
836
+ if (dot <= 0)
837
+ return undefined;
838
+ const controller = qualified.slice(0, dot);
839
+ const field = qualified.slice(dot + 1);
840
+ const model = (_a = byName[controller]) !== null && _a !== void 0 ? _a : {};
841
+ return model[field];
842
+ };
843
+ if (Array.isArray(select)) {
844
+ for (const item of select) {
845
+ if (typeof item === "string") {
846
+ out[item] = getQualified(item);
847
+ continue;
848
+ }
849
+ if (typeof item === "object" && item !== null) {
850
+ for (const [key, as] of Object.entries(item)) {
851
+ if (typeof as !== "string" || as.length === 0)
852
+ continue;
853
+ out[as] = getQualified(key);
854
+ }
855
+ }
856
+ }
857
+ }
858
+ else {
859
+ for (const [key, as] of Object.entries(select)) {
860
+ if (typeof as !== "string" || as.length === 0)
861
+ continue;
862
+ out[as] = getQualified(key);
863
+ }
864
+ }
865
+ return out;
866
+ });
867
+ }
868
+
869
+ /**
870
+ * Registry for controllers, keyed by `controller.name`.
871
+ *
872
+ * Used by React helpers (`ContextProvider`, `useController`, `useRegister`), but
873
+ * can be used in any framework.
874
+ *
875
+ * @example
876
+ * ```ts
877
+ * const store = createObjectStore();
878
+ * store.register(new UsersController("users"));
879
+ * const users = store.get("users");
880
+ * ```
881
+ */
882
+ class ObjectStore {
883
+ constructor() {
884
+ this.store = new Map();
885
+ }
886
+ /**
887
+ * Register a controller instance in this store.
888
+ */
889
+ register(controller) {
890
+ this.store.set(controller.name, controller);
891
+ }
892
+ /**
893
+ * Get a controller by name.
894
+ *
895
+ * Throws if not found. Register controllers up front via `register()`.
896
+ */
897
+ get(name) {
898
+ const controller = this.store.get(name);
899
+ if (!controller) {
900
+ throw Error(`Controller with name ${name} is not registered`);
901
+ }
902
+ return controller;
903
+ }
904
+ /**
905
+ * Remove a controller from the store.
906
+ */
907
+ remove(name) {
908
+ this.store.delete(name);
909
+ }
910
+ /**
911
+ * Initialise all registered controllers.
912
+ *
913
+ * This is equivalent to calling `controller.initialise()` for each controller.
914
+ */
915
+ initialise() {
916
+ this.store.forEach((controller) => {
917
+ controller.initialise();
918
+ });
919
+ }
920
+ }
921
+ const _objectStore = new ObjectStore();
922
+ /**
923
+ * Returns a singleton store instance.
924
+ *
925
+ * `ContextProvider` uses this by default.
926
+ */
927
+ function getDefaultObjectStore() {
928
+ return _objectStore;
929
+ }
930
+ /**
931
+ * Create a new store instance (non-singleton).
932
+ */
933
+ function createObjectStore() {
934
+ return new ObjectStore();
935
+ }
936
+
937
+ /**
938
+ * IndexedDB-backed StorageManager.
939
+ *
940
+ * This is fully async (no in-memory cache needed).
941
+ *
942
+ * Stores snapshots as array-of-models under `${prefix}${name}`.
943
+ */
944
+ class IndexDbStorageManager extends StorageManager {
945
+ constructor(options = {}) {
946
+ var _a, _b, _c;
947
+ super();
948
+ this.dbPromise = null;
949
+ this.dbName = (_a = options.dbName) !== null && _a !== void 0 ? _a : "live-cache";
950
+ this.storeName = (_b = options.storeName) !== null && _b !== void 0 ? _b : "collections";
951
+ this.prefix = (_c = options.prefix) !== null && _c !== void 0 ? _c : "live-cache:";
952
+ }
953
+ key(name) {
954
+ return `${this.prefix}${name}`;
955
+ }
956
+ openDb() {
957
+ if (this.dbPromise)
958
+ return this.dbPromise;
959
+ this.dbPromise = new Promise((resolve, reject) => {
960
+ if (typeof indexedDB === "undefined") {
961
+ reject(new Error("indexedDB is not available in this environment"));
962
+ return;
963
+ }
964
+ const request = indexedDB.open(this.dbName, 1);
965
+ request.onupgradeneeded = () => {
966
+ const db = request.result;
967
+ if (!db.objectStoreNames.contains(this.storeName)) {
968
+ db.createObjectStore(this.storeName);
969
+ }
970
+ };
971
+ request.onsuccess = () => resolve(request.result);
972
+ request.onerror = () => { var _a; return reject((_a = request.error) !== null && _a !== void 0 ? _a : new Error("Failed to open IndexedDB")); };
973
+ });
974
+ return this.dbPromise;
975
+ }
976
+ idbGet(key) {
977
+ return __awaiter(this, void 0, void 0, function* () {
978
+ const db = yield this.openDb();
979
+ return yield new Promise((resolve, reject) => {
980
+ const tx = db.transaction(this.storeName, "readonly");
981
+ const store = tx.objectStore(this.storeName);
982
+ const req = store.get(key);
983
+ req.onsuccess = () => {
984
+ const value = req.result;
985
+ if (!value)
986
+ return resolve(null);
987
+ resolve(Array.isArray(value) ? value : null);
988
+ };
989
+ req.onerror = () => { var _a; return reject((_a = req.error) !== null && _a !== void 0 ? _a : new Error("IndexedDB get failed")); };
990
+ });
991
+ });
992
+ }
993
+ idbSet(key, value) {
994
+ return __awaiter(this, void 0, void 0, function* () {
995
+ const db = yield this.openDb();
996
+ yield new Promise((resolve, reject) => {
997
+ const tx = db.transaction(this.storeName, "readwrite");
998
+ const store = tx.objectStore(this.storeName);
999
+ store.put(value, key);
1000
+ tx.oncomplete = () => resolve();
1001
+ tx.onerror = () => { var _a; return reject((_a = tx.error) !== null && _a !== void 0 ? _a : new Error("IndexedDB set failed")); };
1002
+ tx.onabort = () => { var _a; return reject((_a = tx.error) !== null && _a !== void 0 ? _a : new Error("IndexedDB set aborted")); };
1003
+ });
1004
+ });
1005
+ }
1006
+ idbDelete(key) {
1007
+ return __awaiter(this, void 0, void 0, function* () {
1008
+ const db = yield this.openDb();
1009
+ yield new Promise((resolve, reject) => {
1010
+ const tx = db.transaction(this.storeName, "readwrite");
1011
+ const store = tx.objectStore(this.storeName);
1012
+ store.delete(key);
1013
+ tx.oncomplete = () => resolve();
1014
+ tx.onerror = () => { var _a; return reject((_a = tx.error) !== null && _a !== void 0 ? _a : new Error("IndexedDB delete failed")); };
1015
+ tx.onabort = () => { var _a; return reject((_a = tx.error) !== null && _a !== void 0 ? _a : new Error("IndexedDB delete aborted")); };
1016
+ });
1017
+ });
1018
+ }
1019
+ get(name) {
1020
+ return __awaiter(this, void 0, void 0, function* () {
1021
+ const k = this.key(name);
1022
+ try {
1023
+ const value = yield this.idbGet(k);
1024
+ return (value !== null && value !== void 0 ? value : []);
1025
+ }
1026
+ catch (_a) {
1027
+ return [];
1028
+ }
1029
+ });
1030
+ }
1031
+ set(name, models) {
1032
+ return __awaiter(this, void 0, void 0, function* () {
1033
+ const k = this.key(name);
1034
+ const value = Array.isArray(models) ? models : [];
1035
+ try {
1036
+ yield this.idbSet(k, value);
1037
+ }
1038
+ catch (_a) {
1039
+ // ignore write errors
1040
+ }
1041
+ });
1042
+ }
1043
+ delete(name) {
1044
+ return __awaiter(this, void 0, void 0, function* () {
1045
+ const k = this.key(name);
1046
+ try {
1047
+ yield this.idbDelete(k);
1048
+ }
1049
+ catch (_a) {
1050
+ // ignore delete errors
1051
+ }
1052
+ });
1053
+ }
1054
+ }
1055
+
1056
+ /**
1057
+ * `localStorage`-backed `StorageManager`.
1058
+ *
1059
+ * Stores snapshots as JSON under `${prefix}${name}`.
1060
+ * Reads return `[]` on failure (private mode, JSON parse issues, etc).
1061
+ */
1062
+ class LocalStorageStorageManager extends StorageManager {
1063
+ constructor(prefix = "live-cache:") {
1064
+ super();
1065
+ this.prefix = prefix;
1066
+ }
1067
+ key(name) {
1068
+ return `${this.prefix}${name}`;
1069
+ }
1070
+ get(name) {
1071
+ return __awaiter(this, void 0, void 0, function* () {
1072
+ try {
1073
+ const raw = localStorage.getItem(this.key(name));
1074
+ if (!raw)
1075
+ return [];
1076
+ const parsed = JSON.parse(raw);
1077
+ return Array.isArray(parsed) ? parsed : [];
1078
+ }
1079
+ catch (_a) {
1080
+ return [];
1081
+ }
1082
+ });
1083
+ }
1084
+ set(name, models) {
1085
+ return __awaiter(this, void 0, void 0, function* () {
1086
+ try {
1087
+ localStorage.setItem(this.key(name), JSON.stringify(models));
1088
+ }
1089
+ catch (_a) {
1090
+ // ignore quota / private mode issues
1091
+ }
1092
+ });
1093
+ }
1094
+ delete(name) {
1095
+ return __awaiter(this, void 0, void 0, function* () {
1096
+ try {
1097
+ localStorage.removeItem(this.key(name));
1098
+ }
1099
+ catch (_a) {
1100
+ // ignore
1101
+ }
1102
+ });
1103
+ }
1104
+ }
1105
+
1106
+ const context = React.createContext(null);
1107
+ /**
1108
+ * React context provider for an `ObjectStore`.
1109
+ *
1110
+ * `useController()` reads the store from this context by default.
1111
+ *
1112
+ * @example
1113
+ * ```tsx
1114
+ * <ContextProvider>
1115
+ * <App />
1116
+ * </ContextProvider>
1117
+ * ```
1118
+ */
1119
+ function ContextProvider({ children, store = getDefaultObjectStore(), }) {
1120
+ return React.createElement(context.Provider, { value: store }, children);
1121
+ }
1122
+ /**
1123
+ * Register controllers in a store (defaults to the singleton store).
1124
+ *
1125
+ * This is usually called at component mount time.
1126
+ *
1127
+ * @example
1128
+ * ```tsx
1129
+ * useRegister([usersController, postsController]);
1130
+ * ```
1131
+ */
1132
+ function useRegister(controller, store = getDefaultObjectStore()) {
1133
+ const stored = React.useMemo(() => {
1134
+ controller.forEach((c) => {
1135
+ store.register(c);
1136
+ });
1137
+ return store;
1138
+ }, [store, controller]);
1139
+ return stored;
1140
+ }
1141
+
1142
+ /**
1143
+ * React hook to subscribe to a registered controller.
1144
+ *
1145
+ * - Looks up the controller by name from the `ObjectStore` (context by default)
1146
+ * - Subscribes to `controller.publish()`
1147
+ * - Exposes `data`, `loading`, `error`, and the `controller` instance
1148
+ *
1149
+ * @param name - controller name
1150
+ * @param where - optional `Collection.find()` filter (string `_id` or partial)
1151
+ * @param options - store selection, initialise behavior, abort-on-unmount, and invalidation wiring
1152
+ *
1153
+ * When `options.withInvalidation` is true, this hook calls `controller.invalidate()` once on mount
1154
+ * and calls the returned cleanup function on unmount.
1155
+ *
1156
+ * @example
1157
+ * ```tsx
1158
+ * const { data, controller } = useController<User, "users">("users");
1159
+ * return (
1160
+ * <button onClick={() => void controller.invalidate()}>Refresh</button>
1161
+ * );
1162
+ * ```
1163
+ */
1164
+ function useController(name, where, options) {
1165
+ var _a, _b, _c, _d;
1166
+ const initialise = (_a = options === null || options === void 0 ? void 0 : options.initialise) !== null && _a !== void 0 ? _a : true;
1167
+ const optionalStore = options === null || options === void 0 ? void 0 : options.store;
1168
+ const abortOnUnmount = (_b = options === null || options === void 0 ? void 0 : options.abortOnUnmount) !== null && _b !== void 0 ? _b : true;
1169
+ const withInvalidation = (_c = options === null || options === void 0 ? void 0 : options.withInvalidation) !== null && _c !== void 0 ? _c : true;
1170
+ const [data, setData] = React.useState([]);
1171
+ const [loading, setLoading] = React.useState(false);
1172
+ const [error, setError] = React.useState(null);
1173
+ const defaultStore = React.useContext(context);
1174
+ const store = (_d = optionalStore !== null && optionalStore !== void 0 ? optionalStore : defaultStore) !== null && _d !== void 0 ? _d : null;
1175
+ if (!store) {
1176
+ throw Error("Store is not defined");
1177
+ }
1178
+ const controller = React.useMemo(() => store.get(name), [store, name]);
1179
+ React.useEffect(() => {
1180
+ if (initialise) {
1181
+ controller.initialise();
1182
+ }
1183
+ const callback = () => {
1184
+ var _a;
1185
+ setLoading(controller.loading);
1186
+ setError((_a = controller.error) !== null && _a !== void 0 ? _a : null);
1187
+ setData(controller.collection.find(where).map((item) => item.toModel()));
1188
+ };
1189
+ // Prime state immediately.
1190
+ callback();
1191
+ const cleanup = controller.publish(callback);
1192
+ let cleanupInvalidation = () => { };
1193
+ if (withInvalidation) {
1194
+ cleanupInvalidation = controller.invalidate();
1195
+ }
1196
+ return () => {
1197
+ if (abortOnUnmount) {
1198
+ controller.abort();
1199
+ }
1200
+ cleanup();
1201
+ cleanupInvalidation();
1202
+ };
1203
+ }, [controller, where, initialise, abortOnUnmount, withInvalidation]);
1204
+ return { controller, data, loading, error };
1205
+ }
1206
+
1207
+ /**
1208
+ * React hook that recomputes a `join()` projection whenever any of the `from` controllers commit.
1209
+ *
1210
+ * @example
1211
+ * ```tsx
1212
+ * const rows = useJoinController({
1213
+ * from: [usersController, postsController] as const,
1214
+ * where: { $and: { posts: { userId: { $ref: { controller: "users", field: "id" } } } } } as const,
1215
+ * select: ["users.name", "posts.title"] as const,
1216
+ * });
1217
+ * ```
1218
+ */
1219
+ function useJoinController({ from, where, select }) {
1220
+ const [data, setData] = React.useState([]);
1221
+ React.useEffect(() => {
1222
+ const callback = () => {
1223
+ setData(join(from, where, select));
1224
+ };
1225
+ callback();
1226
+ const cleanup = from.map((c) => c.publish(callback));
1227
+ return () => {
1228
+ cleanup.forEach((c) => c());
1229
+ };
1230
+ }, [from, where, select]);
1231
+ return data;
1232
+ }
1233
+
1234
+ // Main library exports
1235
+ // Default export for UMD/browser usage
1236
+ var index = {
1237
+ Collection,
1238
+ Controller,
1239
+ Document,
1240
+ join,
1241
+ ObjectStore,
1242
+ createObjectStore,
1243
+ getDefaultObjectStore,
1244
+ StorageManager,
1245
+ DefaultStorageManager,
1246
+ IndexDbStorageManager,
1247
+ LocalStorageStorageManager,
1248
+ ContextProvider,
1249
+ useRegister,
1250
+ useController,
1251
+ useJoinController,
1252
+ };
1253
+
1254
+ exports.Collection = Collection;
1255
+ exports.ContextProvider = ContextProvider;
1256
+ exports.Controller = Controller;
1257
+ exports.DefaultStorageManager = DefaultStorageManager;
1258
+ exports.Document = Document;
1259
+ exports.IndexDbStorageManager = IndexDbStorageManager;
1260
+ exports.LocalStorageStorageManager = LocalStorageStorageManager;
1261
+ exports.ObjectStore = ObjectStore;
1262
+ exports.StorageManager = StorageManager;
1263
+ exports.createObjectStore = createObjectStore;
1264
+ exports.default = index;
1265
+ exports.getDefaultObjectStore = getDefaultObjectStore;
1266
+ exports.join = join;
1267
+ exports.useController = useController;
1268
+ exports.useJoinController = useJoinController;
1269
+ exports.useRegister = useRegister;
1270
+
1271
+ Object.defineProperty(exports, '__esModule', { value: true });
1272
+
1273
+ }));
1274
+ //# sourceMappingURL=index.umd.js.map