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 +21 -0
- package/README.md +490 -0
- package/dist/core/Collection.d.ts +92 -0
- package/dist/core/Controller.d.ts +141 -0
- package/dist/core/Document.d.ts +40 -0
- package/dist/core/ObjectStore.d.ts +47 -0
- package/dist/core/StorageManager.d.ts +35 -0
- package/dist/core/join.d.ts +106 -0
- package/dist/index.cjs +1270 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.mjs +1251 -0
- package/dist/index.mjs.map +1 -0
- package/dist/index.umd.js +1274 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/react/Context.d.ts +33 -0
- package/dist/react/useController.d.ts +39 -0
- package/dist/react/useJoinController.d.ts +27 -0
- package/dist/storage-manager/IndexDbStorageManager.d.ts +37 -0
- package/dist/storage-manager/LocalStorageManager.d.ts +15 -0
- package/package.json +57 -0
|
@@ -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
|