@converse/skeletor 3.0.1 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +330 -38
- package/dist/skeletor.d.ts +200 -56
- package/dist/skeletor.esm.js +747 -457
- package/dist/skeletor.esm.js.map +1 -1
- package/dist/skeletor.js +748 -456
- package/dist/skeletor.js.map +1 -1
- package/dist/skeletor.min.js +6 -19
- package/dist/skeletor.node.js +12507 -0
- package/dist/skeletor.node.mjs +12494 -0
- package/package.json +27 -10
- package/src/autosync.ts +156 -0
- package/src/collection.ts +140 -35
- package/src/drivers/helpers.ts +61 -0
- package/src/drivers/nodeSQLiteStorage.ts +162 -0
- package/src/drivers/sessionStorage.ts +16 -22
- package/src/drivers/types.ts +25 -0
- package/src/eventemitter.ts +9 -0
- package/src/helpers.ts +57 -10
- package/src/index.node.ts +16 -0
- package/src/index.ts +11 -3
- package/src/model.ts +254 -13
- package/src/storage.ts +93 -36
- package/src/types.ts +34 -9
- package/src/utils/events.ts +6 -6
package/src/model.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
getResolveablePromise,
|
|
3
|
+
getStorage,
|
|
4
|
+
getSyncMethod,
|
|
5
|
+
urlError,
|
|
6
|
+
warnBrowserStorageDeprecation,
|
|
7
|
+
wrapError,
|
|
8
|
+
} from './helpers';
|
|
9
|
+
import { scheduleAutoSave, cancelAutoSave, awaitInFlightSaves, ensureUnloadListener } from './autosync';
|
|
2
10
|
import clone from 'lodash-es/clone';
|
|
3
11
|
import defaults from 'lodash-es/defaults';
|
|
4
12
|
import defer from 'lodash-es/defer';
|
|
@@ -12,11 +20,20 @@ import pick from 'lodash-es/pick';
|
|
|
12
20
|
import result from 'lodash-es/result';
|
|
13
21
|
import uniqueId from 'lodash-es/uniqueId';
|
|
14
22
|
import { EventEmitterObject } from './eventemitter';
|
|
23
|
+
import PersistentStorage from './storage';
|
|
15
24
|
|
|
16
25
|
// Import types
|
|
17
26
|
import type { Collection } from './collection';
|
|
18
|
-
import
|
|
19
|
-
|
|
27
|
+
import {
|
|
28
|
+
ComputedProperties,
|
|
29
|
+
ComputedProperty,
|
|
30
|
+
EventCallback,
|
|
31
|
+
ModelAttributes,
|
|
32
|
+
ObjectWithId,
|
|
33
|
+
SyncOperation,
|
|
34
|
+
ModelOptions,
|
|
35
|
+
Options,
|
|
36
|
+
} from './types';
|
|
20
37
|
|
|
21
38
|
/**
|
|
22
39
|
* @public
|
|
@@ -26,17 +43,22 @@ import { ModelAttributes, ObjectWithId, SyncOperation, ModelOptions, Options } f
|
|
|
26
43
|
* performing computations and transformations on that data.
|
|
27
44
|
*/
|
|
28
45
|
export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmitterObject {
|
|
29
|
-
|
|
46
|
+
#computedCache?: Record<string, any>;
|
|
47
|
+
#computedDefs?: Record<string, ComputedProperty<this>>;
|
|
48
|
+
#state: 'constructing' | 'hydrating' | 'ready' = 'constructing';
|
|
49
|
+
_storage?: PersistentStorage;
|
|
30
50
|
_changing = false;
|
|
31
51
|
_pending: boolean | ModelOptions = false;
|
|
32
52
|
_previousAttributes?: T;
|
|
33
53
|
_url: string = '';
|
|
34
54
|
_urlRoot: string;
|
|
55
|
+
attrs: T;
|
|
35
56
|
attributes: T;
|
|
36
57
|
changed: Partial<T> = {};
|
|
37
58
|
cid: string;
|
|
38
59
|
collection?: Collection;
|
|
39
60
|
id: string | number;
|
|
61
|
+
initialized?: Promise<void>;
|
|
40
62
|
validationError: string | number | null = null;
|
|
41
63
|
|
|
42
64
|
/**
|
|
@@ -60,18 +82,99 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
60
82
|
|
|
61
83
|
this.set(attrs, options);
|
|
62
84
|
|
|
85
|
+
this.#initComputed();
|
|
86
|
+
|
|
87
|
+
this.attrs = new Proxy(this.attributes as T, {
|
|
88
|
+
get: (_target, key) => {
|
|
89
|
+
if (typeof key !== 'symbol' && this.#computedCache && (key as string) in this.#computedCache) {
|
|
90
|
+
return this.#computedCache[key as string];
|
|
91
|
+
}
|
|
92
|
+
return (this.attributes as any)[key];
|
|
93
|
+
},
|
|
94
|
+
set: (_target, key, value) => {
|
|
95
|
+
this.set(key as string, value);
|
|
96
|
+
return true;
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
63
100
|
this.initialize.apply(this, arguments as any);
|
|
64
101
|
|
|
65
102
|
// Reset changed after initial set
|
|
66
103
|
this.changed = {};
|
|
104
|
+
|
|
105
|
+
// When autoSync is on, `initialized` is always a Promise so callers can
|
|
106
|
+
// uniformly `await model.initialized`. It only hydrates from storage when
|
|
107
|
+
// there's something to load (storage configured and the model isn't new);
|
|
108
|
+
// otherwise it's already ready and resolves immediately.
|
|
109
|
+
if (this.autoSync) {
|
|
110
|
+
if (getStorage(this) && !this.isNew()) {
|
|
111
|
+
this.#state = 'hydrating';
|
|
112
|
+
this.initialized = this.#hydrate();
|
|
113
|
+
} else {
|
|
114
|
+
this.#state = 'ready';
|
|
115
|
+
this.initialized = Promise.resolve();
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
this.#state = 'ready';
|
|
119
|
+
}
|
|
67
120
|
}
|
|
68
121
|
|
|
69
|
-
|
|
70
|
-
|
|
122
|
+
/**
|
|
123
|
+
* The canonical storage accessor. Set to a `PersistentStorage` instance
|
|
124
|
+
* to enable local persistence for this model.
|
|
125
|
+
*/
|
|
126
|
+
get storage(): PersistentStorage | undefined {
|
|
127
|
+
return this._storage;
|
|
71
128
|
}
|
|
72
129
|
|
|
73
|
-
|
|
74
|
-
|
|
130
|
+
set storage(s: PersistentStorage) {
|
|
131
|
+
this._storage = s;
|
|
132
|
+
PersistentStorage.register(s);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @deprecated Use `storage` instead.
|
|
137
|
+
*/
|
|
138
|
+
get browserStorage(): PersistentStorage | undefined {
|
|
139
|
+
return this._storage;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
set browserStorage(s: PersistentStorage) {
|
|
143
|
+
warnBrowserStorageDeprecation(this);
|
|
144
|
+
this.storage = s;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Override to enable automatic persistence. When true, any `set()` call
|
|
149
|
+
* that changes attributes will schedule a debounced save to `storage`, and
|
|
150
|
+
* the model will auto-hydrate from storage on construction.
|
|
151
|
+
*/
|
|
152
|
+
get autoSync(): boolean {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Debounce delay (ms) for auto-save writes. Override to tune.
|
|
158
|
+
*/
|
|
159
|
+
get autoSyncDelay(): number {
|
|
160
|
+
return 100;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async #hydrate(): Promise<void> {
|
|
164
|
+
ensureUnloadListener(PersistentStorage);
|
|
165
|
+
try {
|
|
166
|
+
await this.fetch({ fromStorage: true, promise: true });
|
|
167
|
+
} catch (e) {
|
|
168
|
+
// 'Record Not Found' is a normal first-run state (nothing stored yet);
|
|
169
|
+
// keep the initial attributes and resolve. Re-throw anything else.
|
|
170
|
+
if (e !== 'Record Not Found') throw e;
|
|
171
|
+
} finally {
|
|
172
|
+
this.#state = 'ready';
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
#doAutoSave(): unknown {
|
|
177
|
+
return this.save(null, { noAutoSave: true });
|
|
75
178
|
}
|
|
76
179
|
|
|
77
180
|
/**
|
|
@@ -114,6 +217,39 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
114
217
|
return {} as Partial<T>;
|
|
115
218
|
}
|
|
116
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Declare computed properties. Override in subclasses to define properties
|
|
222
|
+
* that derive their value from other attributes. Each entry specifies the
|
|
223
|
+
* attribute keys it depends on (`deps`) and a function (`fn`) that receives
|
|
224
|
+
* the model and returns the computed value. The value is cached and
|
|
225
|
+
* recalculated automatically when any dep changes, firing a `change:key`
|
|
226
|
+
* event.
|
|
227
|
+
*/
|
|
228
|
+
get computed(): ComputedProperties<this> {
|
|
229
|
+
return {};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#updateComputed(key: string, def: ComputedProperty<this>, silent: boolean, options: ModelOptions): void {
|
|
233
|
+
const newVal = def.fn(this);
|
|
234
|
+
if (!isEqual(this.#computedCache[key], newVal)) {
|
|
235
|
+
this.#computedCache[key] = newVal;
|
|
236
|
+
if (!silent) {
|
|
237
|
+
(this.changed as any)[key] = newVal;
|
|
238
|
+
this.trigger('change:' + key, this, newVal, options);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#initComputed(): void {
|
|
244
|
+
const defs = result(this, 'computed') as ComputedProperties<this>;
|
|
245
|
+
if (!defs || isEmpty(defs)) return;
|
|
246
|
+
this.#computedDefs = defs;
|
|
247
|
+
this.#computedCache = {};
|
|
248
|
+
for (const [key, def] of Object.entries(defs)) {
|
|
249
|
+
this.#computedCache[key] = def.fn(this);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
117
253
|
/**
|
|
118
254
|
* Return a copy of the model's `attributes` object.
|
|
119
255
|
*/
|
|
@@ -129,9 +265,12 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
129
265
|
}
|
|
130
266
|
|
|
131
267
|
/**
|
|
132
|
-
* Get the value of an attribute.
|
|
268
|
+
* Get the value of an attribute, or a computed property.
|
|
133
269
|
*/
|
|
134
270
|
get<K extends keyof T>(attr: K): T[K] {
|
|
271
|
+
if (this.#computedCache && (attr as string) in this.#computedCache) {
|
|
272
|
+
return this.#computedCache[attr as string];
|
|
273
|
+
}
|
|
135
274
|
return this.attributes[attr];
|
|
136
275
|
}
|
|
137
276
|
|
|
@@ -208,6 +347,14 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
208
347
|
|
|
209
348
|
options = options || {};
|
|
210
349
|
|
|
350
|
+
// Guard must come after arg normalization: when called as set(obj, opts),
|
|
351
|
+
// fromStorage lives in `val` (second arg), not `options` (third arg).
|
|
352
|
+
if (this.autoSync && this.#state === 'hydrating' && !options.fromStorage) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
`Skeletor: set() called on an autoSync model before initialized resolved. Await model.initialized first.`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
211
358
|
// Run validation.
|
|
212
359
|
if (!this._validate(attrs, options)) return null;
|
|
213
360
|
|
|
@@ -250,6 +397,15 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
250
397
|
}
|
|
251
398
|
}
|
|
252
399
|
|
|
400
|
+
// Recalculate computed properties whose deps changed.
|
|
401
|
+
if (changes.length && this.#computedDefs) {
|
|
402
|
+
for (const [key, def] of Object.entries(this.#computedDefs)) {
|
|
403
|
+
if (def.deps.some((dep) => changes.includes(dep))) {
|
|
404
|
+
this.#updateComputed(key, def, silent, options);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
253
409
|
if (changing) return this;
|
|
254
410
|
|
|
255
411
|
if (!silent) {
|
|
@@ -263,6 +419,21 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
263
419
|
}
|
|
264
420
|
this._pending = false;
|
|
265
421
|
this._changing = false;
|
|
422
|
+
|
|
423
|
+
// Auto-save: schedule a debounced write when autoSync is on and there
|
|
424
|
+
// were actual changes that originated from application code (not a
|
|
425
|
+
// storage read or an explicit noAutoSave call).
|
|
426
|
+
if (
|
|
427
|
+
this.#state === 'ready' &&
|
|
428
|
+
this.autoSync &&
|
|
429
|
+
getStorage(this) &&
|
|
430
|
+
changes.length &&
|
|
431
|
+
!options.fromStorage &&
|
|
432
|
+
!options.noAutoSave
|
|
433
|
+
) {
|
|
434
|
+
scheduleAutoSave(this, () => this.#doAutoSave(), this.autoSyncDelay);
|
|
435
|
+
}
|
|
436
|
+
|
|
266
437
|
return this;
|
|
267
438
|
}
|
|
268
439
|
|
|
@@ -342,16 +513,50 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
342
513
|
options = Object.assign({ parse: true }, options);
|
|
343
514
|
|
|
344
515
|
const success = options.success;
|
|
516
|
+
const promise = options.promise ? getResolveablePromise() : undefined;
|
|
517
|
+
|
|
518
|
+
// Tracks whether sync invoked one of the callbacks. A sync may instead
|
|
519
|
+
// settle its returned promise; the `.then`/`.catch` below bridges that to
|
|
520
|
+
// the callbacks so the fetch promise never hangs. The flag is set
|
|
521
|
+
// synchronously inside each callback (before sync's promise resolves), so
|
|
522
|
+
// it's reliable by the time those handlers run.
|
|
523
|
+
let settled = false;
|
|
345
524
|
|
|
346
525
|
options.success = (resp: any) => {
|
|
526
|
+
settled = true;
|
|
347
527
|
const serverAttrs = options.parse ? this.parse(resp, options) : resp;
|
|
348
|
-
|
|
528
|
+
// fromStorage prevents the set() from triggering auto-save on reads
|
|
529
|
+
if (!this.set(serverAttrs, Object.assign({}, options, { fromStorage: true }))) return false;
|
|
349
530
|
if (success) success.call(options.context, this, resp, options);
|
|
350
531
|
this.trigger('sync', this, resp, options);
|
|
532
|
+
promise && promise.resolve(resp);
|
|
351
533
|
};
|
|
352
534
|
|
|
353
|
-
wrapError(this, options);
|
|
354
|
-
|
|
535
|
+
wrapError(this, options, promise);
|
|
536
|
+
if (promise) {
|
|
537
|
+
const onError = options.error;
|
|
538
|
+
options.error = (resp: any) => {
|
|
539
|
+
settled = true;
|
|
540
|
+
onError(resp);
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const result = this.sync('read', this, options);
|
|
545
|
+
if (promise) {
|
|
546
|
+
Promise.resolve(result).then(
|
|
547
|
+
// A sync that resolves its promise without invoking options.success:
|
|
548
|
+
// treat the resolved value as the response, so the model is still
|
|
549
|
+
// merged and the promise settles instead of hanging.
|
|
550
|
+
(value) => {
|
|
551
|
+
if (!settled) options.success(value);
|
|
552
|
+
},
|
|
553
|
+
// A sync that rejects instead of invoking options.error; settling
|
|
554
|
+
// twice is a harmless no-op.
|
|
555
|
+
(e) => promise.reject(e),
|
|
556
|
+
);
|
|
557
|
+
return promise;
|
|
558
|
+
}
|
|
559
|
+
return result;
|
|
355
560
|
}
|
|
356
561
|
|
|
357
562
|
/**
|
|
@@ -431,6 +636,7 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
431
636
|
* If `wait: true` is passed, waits for the server to respond before removal.
|
|
432
637
|
*/
|
|
433
638
|
destroy(options?: ModelOptions): any {
|
|
639
|
+
cancelAutoSave(this);
|
|
434
640
|
options = options ? clone(options) : {};
|
|
435
641
|
const success = options.success;
|
|
436
642
|
const wait = options.wait;
|
|
@@ -446,12 +652,24 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
446
652
|
if (!this.isNew()) this.trigger('sync', this, resp, options);
|
|
447
653
|
};
|
|
448
654
|
|
|
655
|
+
// A debounced auto-save may already have fired and be in flight.
|
|
656
|
+
// cancelAutoSave() above only stops a *pending* timer, so sequence the
|
|
657
|
+
// delete after any in-flight save; otherwise the late write could land
|
|
658
|
+
// after the delete and resurrect the record. Capture the collection now,
|
|
659
|
+
// the optimistic in-memory removal below nulls `model.collection` before
|
|
660
|
+
// the deferred sync runs, but the storage layer still needs it to drop
|
|
661
|
+
// this id from the collection's persisted index.
|
|
662
|
+
const inFlightSave = awaitInFlightSaves(this);
|
|
663
|
+
if (inFlightSave && this.collection) options.collection = this.collection;
|
|
664
|
+
|
|
449
665
|
let xhr: any = false;
|
|
450
666
|
if (this.isNew()) {
|
|
451
667
|
defer(options.success);
|
|
452
668
|
} else {
|
|
453
669
|
wrapError(this, options);
|
|
454
|
-
xhr =
|
|
670
|
+
xhr = inFlightSave
|
|
671
|
+
? inFlightSave.then(() => this.sync('delete', this, options))
|
|
672
|
+
: this.sync('delete', this, options);
|
|
455
673
|
}
|
|
456
674
|
if (!wait) destroy();
|
|
457
675
|
return xhr;
|
|
@@ -505,6 +723,29 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
505
723
|
return this._validate({}, Object.assign({}, options, { validate: true }));
|
|
506
724
|
}
|
|
507
725
|
|
|
726
|
+
/**
|
|
727
|
+
* Subscribe to model changes. Returns an unsubscribe function.
|
|
728
|
+
*
|
|
729
|
+
* `model.subscribe(callback)` — fires on any attribute change, receives `(model, changed)`.
|
|
730
|
+
* `model.subscribe(event, callback)` — subscribe to a specific event (base EventEmitter form).
|
|
731
|
+
*
|
|
732
|
+
* Compatible with React's `useSyncExternalStore` and other store-style APIs.
|
|
733
|
+
*/
|
|
734
|
+
subscribe(event: string, callback: EventCallback, context?: unknown): () => void;
|
|
735
|
+
subscribe(callback: (model: this, changed: Partial<T>) => void): () => void;
|
|
736
|
+
subscribe(
|
|
737
|
+
eventOrCallback: string | ((model: this, changed: Partial<T>) => void),
|
|
738
|
+
callback?: EventCallback,
|
|
739
|
+
context?: unknown,
|
|
740
|
+
): () => void {
|
|
741
|
+
if (typeof eventOrCallback === 'function') {
|
|
742
|
+
const cb = (model: this) => (eventOrCallback as (model: this, changed: Partial<T>) => void)(model, this.changed);
|
|
743
|
+
this.on('change', cb);
|
|
744
|
+
return () => this.off('change', cb);
|
|
745
|
+
}
|
|
746
|
+
return super.subscribe(eventOrCallback, callback, context);
|
|
747
|
+
}
|
|
748
|
+
|
|
508
749
|
/**
|
|
509
750
|
* Run validation against the next complete set of model attributes,
|
|
510
751
|
* returning `true` if all is well. Otherwise, fire an `"invalid"` event.
|
package/src/storage.ts
CHANGED
|
@@ -13,54 +13,87 @@ import { guid } from './helpers';
|
|
|
13
13
|
import type { Model } from './model';
|
|
14
14
|
import type { Collection } from './collection';
|
|
15
15
|
import type { SyncOptions, SyncOperation } from './types';
|
|
16
|
+
import { StorageDriver, StoreType } from './drivers/types';
|
|
16
17
|
|
|
17
18
|
const IN_MEMORY = memoryDriver._driver;
|
|
18
19
|
localForage.defineDriver(memoryDriver);
|
|
19
20
|
extendPrototypeWithSetItems(localForage);
|
|
20
21
|
extendPrototypeWithGetItems(localForage);
|
|
21
22
|
|
|
23
|
+
// Every storage instance is registered with a WeakRef so that flushAll() can
|
|
24
|
+
// reach pending writes without keeping instances alive. There's no leak and no
|
|
25
|
+
// lifecycle hook to maintain: an instance is collected once its owning model or
|
|
26
|
+
// collection is gone, at which point nothing is writing through it anyway.
|
|
27
|
+
// Models with a pending debounced save stay reachable via the autosync `pending`
|
|
28
|
+
// map until the timer fires, so flushAll() always reaches in-flight writes.
|
|
29
|
+
const weakInstances = new Set<WeakRef<PersistentStorage>>();
|
|
30
|
+
|
|
31
|
+
// Tracks which instances already have a WeakRef in `weakInstances`, so repeated
|
|
32
|
+
// `storage =` assignments (or a store shared across models) don't register the
|
|
33
|
+
// same instance twice and make flushAll() flush it more than once.
|
|
34
|
+
const registered = new WeakSet<PersistentStorage>();
|
|
35
|
+
|
|
22
36
|
/**
|
|
23
|
-
*
|
|
37
|
+
* Constructor for a Node.js-only storage driver. Registered by the
|
|
38
|
+
* `@converse/skeletor/node` entry point via {@link PersistentStorage.nodeStorage}.
|
|
24
39
|
*/
|
|
25
|
-
|
|
26
|
-
setItem(key: string, value: any): Promise<any>;
|
|
27
|
-
getItem(key: string): Promise<any>;
|
|
28
|
-
removeItem(key: string): Promise<void>;
|
|
29
|
-
clear(): Promise<void>;
|
|
30
|
-
length(): Promise<number>;
|
|
31
|
-
key(keyIndex: number): Promise<string>;
|
|
32
|
-
keys(): Promise<string[]>;
|
|
33
|
-
setItems?(items: Record<string, any>): Promise<void>;
|
|
34
|
-
getItems?(keys: string[]): Promise<Record<string, any>>;
|
|
35
|
-
debouncedSetItems?: {
|
|
36
|
-
(items: Record<string, any>): Promise<void>;
|
|
37
|
-
flush?: () => void;
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
+
type NodeStorageConstructor = new (name: string) => StorageDriver;
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* @public
|
|
43
44
|
*/
|
|
44
|
-
class
|
|
45
|
+
class PersistentStorage {
|
|
45
46
|
storeInitialized: Promise<void>;
|
|
46
|
-
|
|
47
|
+
// Set once `storeInitialized` resolves. Lets `sync()` skip awaiting it on the
|
|
48
|
+
// hot path so a save buffers/issues its write *synchronously*, which is what
|
|
49
|
+
// makes the synchronous unload flush (`flushPending` -> `flushAll`) able to
|
|
50
|
+
// push a pending write out before the page is torn down.
|
|
51
|
+
storeReady = false;
|
|
52
|
+
store: StorageDriver;
|
|
47
53
|
name: string;
|
|
48
54
|
|
|
49
55
|
static sessionStorageInitialized: Promise<void>;
|
|
50
56
|
static localForage: typeof localForage;
|
|
51
57
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
// The Node.js SQLite storage driver. Left null in browser builds so the
|
|
59
|
+
// node-only driver (and its `node:sqlite` import) never enters the browser
|
|
60
|
+
// bundle; the `@converse/skeletor/node` entry sets it.
|
|
61
|
+
static nodeStorage: NodeStorageConstructor | null = null;
|
|
62
|
+
|
|
63
|
+
/** Register a storage instance with a weak reference. @public */
|
|
64
|
+
static register(instance: PersistentStorage): void {
|
|
65
|
+
if (registered.has(instance)) return;
|
|
66
|
+
registered.add(instance);
|
|
67
|
+
weakInstances.add(new WeakRef(instance));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Flush the debounced write buffer of every registered PersistentStorage
|
|
72
|
+
* instance. Called automatically on pagehide/visibilitychange when autoSync
|
|
73
|
+
* is in use; can also be called manually.
|
|
74
|
+
* @public
|
|
75
|
+
*/
|
|
76
|
+
static flushAll(): void {
|
|
77
|
+
for (const ref of weakInstances) {
|
|
78
|
+
const instance = ref.deref();
|
|
79
|
+
if (!instance) {
|
|
80
|
+
weakInstances.delete(ref);
|
|
81
|
+
} else {
|
|
82
|
+
instance.flush();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
constructor(id: string, type: StoreType | StorageDriver, batchedWrites = false) {
|
|
88
|
+
this.name = id;
|
|
89
|
+
|
|
90
|
+
if (type === 'local' && typeof window !== 'undefined' && !window.localStorage) {
|
|
58
91
|
throw new Error('Skeletor.storage: Environment does not support localStorage.');
|
|
59
|
-
} else if (type === 'session' && !window.sessionStorage) {
|
|
92
|
+
} else if (type === 'session' && typeof window !== 'undefined' && !window.sessionStorage) {
|
|
60
93
|
throw new Error('Skeletor.storage: Environment does not support sessionStorage.');
|
|
61
94
|
}
|
|
62
95
|
if (isString(type)) {
|
|
63
|
-
this.storeInitialized = this.initStore(type as
|
|
96
|
+
this.storeInitialized = this.initStore(type as StoreType, batchedWrites);
|
|
64
97
|
} else {
|
|
65
98
|
this.store = type;
|
|
66
99
|
if (batchedWrites) {
|
|
@@ -70,24 +103,42 @@ class BrowserStorage {
|
|
|
70
103
|
}
|
|
71
104
|
this.storeInitialized = Promise.resolve();
|
|
72
105
|
}
|
|
73
|
-
|
|
106
|
+
// Flip the synchronous-readiness flag once init settles. Callers that await
|
|
107
|
+
// `storeInitialized` directly still see any rejection; this branch only
|
|
108
|
+
// exists to set the flag and must not raise an unhandled rejection.
|
|
109
|
+
this.storeInitialized.then(() => (this.storeReady = true)).catch(() => {});
|
|
74
110
|
}
|
|
75
111
|
|
|
76
112
|
/**
|
|
77
|
-
* @param type - The storage type: 'local', 'session', 'indexed', or 'in_memory'
|
|
113
|
+
* @param type - The storage type: 'local', 'session', 'indexed', 'node', or 'in_memory'
|
|
78
114
|
* @param batchedWrites - Whether to enable batched writes
|
|
79
115
|
*/
|
|
80
|
-
async initStore(type:
|
|
116
|
+
async initStore(type: StoreType, batchedWrites: boolean): Promise<void> {
|
|
81
117
|
if (type === 'session') {
|
|
82
118
|
await localForage.setDriver(sessionStorageWrapper._driver);
|
|
83
119
|
} else if (type === 'local') {
|
|
84
120
|
await localForage.config({ 'driver': localForage.LOCALSTORAGE });
|
|
85
121
|
} else if (type === 'in_memory') {
|
|
86
122
|
await localForage.config({ 'driver': IN_MEMORY });
|
|
123
|
+
} else if (type === 'node') {
|
|
124
|
+
const NodeSQLiteStorage = PersistentStorage.nodeStorage;
|
|
125
|
+
if (!NodeSQLiteStorage) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
"Skeletor.storage: the 'node' storage type is only available in Node.js builds. " +
|
|
128
|
+
"Import from '@converse/skeletor/node' instead of '@converse/skeletor'."
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
this.store = new NodeSQLiteStorage(this.name);
|
|
132
|
+
if (batchedWrites) {
|
|
133
|
+
this.store.debouncedSetItems = mergebounce((items: Record<string, any>) => this.store.setItems!(items), 50, {
|
|
134
|
+
'promise': true,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
87
138
|
} else if (type !== 'indexed') {
|
|
88
139
|
throw new Error('Skeletor.storage: No storage type was specified');
|
|
89
140
|
}
|
|
90
|
-
this.store = localForage as
|
|
141
|
+
this.store = localForage as StorageDriver;
|
|
91
142
|
if (batchedWrites) {
|
|
92
143
|
this.store.debouncedSetItems = mergebounce((items: Record<string, any>) => this.store.setItems!(items), 50, {
|
|
93
144
|
'promise': true,
|
|
@@ -102,6 +153,7 @@ class BrowserStorage {
|
|
|
102
153
|
}
|
|
103
154
|
|
|
104
155
|
async clear(): Promise<void> {
|
|
156
|
+
await this.storeInitialized;
|
|
105
157
|
await this.store.removeItem(this.name).catch((e) => console.error(e));
|
|
106
158
|
const re = new RegExp(`^${this.name}-`);
|
|
107
159
|
const keys = await this.store.keys();
|
|
@@ -119,12 +171,17 @@ class BrowserStorage {
|
|
|
119
171
|
// We get the collection (and if necessary the model attribute.
|
|
120
172
|
// Waiting for storeInitialized will cause another iteration of
|
|
121
173
|
// the event loop, after which the collection reference will
|
|
122
|
-
// be removed from the model.
|
|
123
|
-
|
|
174
|
+
// be removed from the model. `options.collection` lets a deferred
|
|
175
|
+
// delete (sequenced behind an in-flight auto-save by Model.destroy)
|
|
176
|
+
// supply the collection captured before its optimistic removal.
|
|
177
|
+
const collection = options.collection ?? model.collection;
|
|
124
178
|
if (['patch', 'update'].includes(method)) {
|
|
125
179
|
new_attributes = cloneDeep(model.attributes);
|
|
126
180
|
}
|
|
127
|
-
|
|
181
|
+
|
|
182
|
+
// Skip `await` if not necessary to allow for sync flushing
|
|
183
|
+
if (!that.storeReady) await that.storeInitialized;
|
|
184
|
+
|
|
128
185
|
try {
|
|
129
186
|
const original_attributes = model.attributes;
|
|
130
187
|
switch (method) {
|
|
@@ -288,6 +345,6 @@ class BrowserStorage {
|
|
|
288
345
|
}
|
|
289
346
|
}
|
|
290
347
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
export default
|
|
348
|
+
PersistentStorage.sessionStorageInitialized = localForage.defineDriver(sessionStorageWrapper);
|
|
349
|
+
PersistentStorage.localForage = localForage;
|
|
350
|
+
export default PersistentStorage;
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {Collection} from './collection';
|
|
2
|
-
import {Model} from './model';
|
|
1
|
+
import { Collection } from './collection';
|
|
2
|
+
import { Model } from './model';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* @public
|
|
@@ -9,7 +9,7 @@ export type SyncOperation = 'create' | 'update' | 'patch' | 'delete' | 'read';
|
|
|
9
9
|
/**
|
|
10
10
|
* @public
|
|
11
11
|
*/
|
|
12
|
-
export type ObjectListenedTo = object & {_listenId?: string};
|
|
12
|
+
export type ObjectListenedTo = object & { _listenId?: string };
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* @public
|
|
@@ -22,6 +22,11 @@ export interface SyncOptions {
|
|
|
22
22
|
error?: (error: any) => void;
|
|
23
23
|
xhr?: any;
|
|
24
24
|
wait?: boolean;
|
|
25
|
+
// Lets `Model.destroy` pass the collection captured before its optimistic
|
|
26
|
+
// (synchronous) in-memory removal nulled `model.collection`, so the storage
|
|
27
|
+
// layer can still drop the id from the collection's persisted index even
|
|
28
|
+
// when the delete is deferred behind an in-flight auto-save.
|
|
29
|
+
collection?: Collection;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
/**
|
|
@@ -70,7 +75,7 @@ export type ListeningMap = Record<string, ListeningType>;
|
|
|
70
75
|
/**
|
|
71
76
|
* @public
|
|
72
77
|
*/
|
|
73
|
-
export type ObjectWithId = Record<string, any> & {id: string | number};
|
|
78
|
+
export type ObjectWithId = Record<string, any> & { id: string | number };
|
|
74
79
|
|
|
75
80
|
/**
|
|
76
81
|
* @public
|
|
@@ -85,7 +90,7 @@ export interface IEventEmitter {
|
|
|
85
90
|
off(
|
|
86
91
|
name?: string | EventCallbackMap | null,
|
|
87
92
|
callback?: EventCallback | EventContext | null,
|
|
88
|
-
context?: EventContext
|
|
93
|
+
context?: EventContext,
|
|
89
94
|
): this;
|
|
90
95
|
trigger(name: string, ...args: any[]): this;
|
|
91
96
|
stopListening(obj?: any, name?: string | EventCallbackMap, callback?: EventCallback): this;
|
|
@@ -135,7 +140,7 @@ export interface EventsApiOptions {
|
|
|
135
140
|
*/
|
|
136
141
|
export interface OffApiOptions {
|
|
137
142
|
context?: any;
|
|
138
|
-
listeners?: {[key: string]: ListeningType};
|
|
143
|
+
listeners?: { [key: string]: ListeningType };
|
|
139
144
|
}
|
|
140
145
|
|
|
141
146
|
/**
|
|
@@ -155,21 +160,37 @@ export type IterateeFunction = (
|
|
|
155
160
|
eventsOrMap: EventCallbackMap | EventHandlersMap,
|
|
156
161
|
name: string | null,
|
|
157
162
|
callback: EventCallback | EventHandler | null,
|
|
158
|
-
options: EventsApiOptions | OffApiOptions | OfferFunction | any[]
|
|
163
|
+
options: EventsApiOptions | OffApiOptions | OfferFunction | any[],
|
|
159
164
|
) => EventCallbackMap | EventHandlersMap | void;
|
|
160
165
|
|
|
161
166
|
/**
|
|
162
167
|
* @public
|
|
163
168
|
*/
|
|
164
|
-
export type
|
|
169
|
+
export type ComputedProperty<M> = {
|
|
170
|
+
deps: string[];
|
|
171
|
+
fn: (model: M) => any;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @public
|
|
176
|
+
*/
|
|
177
|
+
export type ComputedProperties<M> = Record<string, ComputedProperty<M>>;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @public
|
|
181
|
+
*/
|
|
182
|
+
export type ModelAttributes = Record<string | number, any> & { id?: string | number };
|
|
165
183
|
|
|
166
184
|
/**
|
|
167
185
|
* @public
|
|
168
186
|
*/
|
|
169
187
|
export type Options = Record<string, any>;
|
|
170
188
|
|
|
189
|
+
/**
|
|
190
|
+
* @public
|
|
191
|
+
*/
|
|
171
192
|
export type FetchOrCreateOptions = Options & {
|
|
172
|
-
promise?: boolean
|
|
193
|
+
promise?: boolean;
|
|
173
194
|
success?: (m: Model, resp: any, callbackOpts: Options) => void;
|
|
174
195
|
};
|
|
175
196
|
|
|
@@ -191,4 +212,8 @@ export type ModelOptions = Options & {
|
|
|
191
212
|
unset?: boolean;
|
|
192
213
|
silent?: boolean;
|
|
193
214
|
validate?: boolean;
|
|
215
|
+
/** Suppress the auto-save triggered by set() on autoSync models. */
|
|
216
|
+
noAutoSave?: boolean;
|
|
217
|
+
/** Mark a set() call as originating from a storage read; suppresses auto-save. */
|
|
218
|
+
fromStorage?: boolean;
|
|
194
219
|
};
|