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