@converse/skeletor 3.1.6 → 4.0.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/CHANGES.md +55 -0
- package/dist/skeletor.d.ts +922 -931
- package/dist/skeletor.esm.js +366 -246
- package/dist/skeletor.esm.js.map +1 -1
- package/dist/skeletor.js +366 -245
- package/dist/skeletor.js.map +1 -1
- package/dist/skeletor.min.js +5 -5
- package/dist/skeletor.node.d.ts +947 -956
- package/dist/skeletor.node.js +366 -245
- package/dist/skeletor.node.mjs +366 -246
- package/package.json +7 -10
- package/src/collection.ts +82 -87
- package/src/helpers.ts +135 -25
- package/src/index.ts +4 -2
- package/src/model.ts +128 -100
package/src/model.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
bridgeSyncToPromise,
|
|
2
3
|
getResolveablePromise,
|
|
3
4
|
getStorage,
|
|
4
5
|
getSyncMethod,
|
|
@@ -14,7 +15,7 @@ import has from 'lodash-es/has';
|
|
|
14
15
|
import invert from 'lodash-es/invert';
|
|
15
16
|
import isEmpty from 'lodash-es/isEmpty';
|
|
16
17
|
import isEqual from 'lodash-es/isEqual';
|
|
17
|
-
import
|
|
18
|
+
import isMatch from 'lodash-es/isMatch';
|
|
18
19
|
import omit from 'lodash-es/omit';
|
|
19
20
|
import pick from 'lodash-es/pick';
|
|
20
21
|
import result from 'lodash-es/result';
|
|
@@ -35,6 +36,72 @@ import {
|
|
|
35
36
|
Options,
|
|
36
37
|
} from './types';
|
|
37
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Per-instance state that is private to this module.
|
|
41
|
+
*
|
|
42
|
+
* It lives in a WeakMap rather than in ES `#private` fields so that the
|
|
43
|
+
* published declarations carry no private members. TypeScript 6 refuses to
|
|
44
|
+
* emit an anonymous class type whose base has private members (TS4094), and
|
|
45
|
+
* that is exactly what the usual mixin pattern (`class extends Base`) over
|
|
46
|
+
* `Model` produces in consumers such as Converse.
|
|
47
|
+
*/
|
|
48
|
+
type ModelInternals = {
|
|
49
|
+
state: 'constructing' | 'hydrating' | 'ready';
|
|
50
|
+
computedCache?: Record<string, any>;
|
|
51
|
+
computedDefs?: ComputedProperties<any>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const INTERNALS = new WeakMap<Model<any>, ModelInternals>();
|
|
55
|
+
|
|
56
|
+
function internals(model: Model<any>): ModelInternals {
|
|
57
|
+
let priv = INTERNALS.get(model);
|
|
58
|
+
if (!priv) {
|
|
59
|
+
priv = { state: 'constructing' };
|
|
60
|
+
INTERNALS.set(model, priv);
|
|
61
|
+
}
|
|
62
|
+
return priv;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Load the model from storage, marking it ready whether or not the read succeeds. */
|
|
66
|
+
async function hydrate(model: Model<any>, priv: ModelInternals): Promise<void> {
|
|
67
|
+
ensureUnloadListener(PersistentStorage);
|
|
68
|
+
try {
|
|
69
|
+
await model.fetch({ fromStorage: true, promise: true });
|
|
70
|
+
} finally {
|
|
71
|
+
priv.state = 'ready';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Evaluate the model's declared computed properties once, seeding the cache. */
|
|
76
|
+
function initComputed(model: Model<any>, priv: ModelInternals): void {
|
|
77
|
+
const defs = result(model, 'computed') as ComputedProperties<any>;
|
|
78
|
+
if (!defs || isEmpty(defs)) return;
|
|
79
|
+
priv.computedDefs = defs;
|
|
80
|
+
priv.computedCache = {};
|
|
81
|
+
for (const [key, def] of Object.entries(defs)) {
|
|
82
|
+
priv.computedCache[key] = def.fn(model);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Recompute one computed property, firing `change:key` when its value changed. */
|
|
87
|
+
function updateComputed(
|
|
88
|
+
model: Model<any>,
|
|
89
|
+
priv: ModelInternals,
|
|
90
|
+
key: string,
|
|
91
|
+
def: ComputedProperty<any>,
|
|
92
|
+
silent: boolean,
|
|
93
|
+
options: ModelOptions,
|
|
94
|
+
): void {
|
|
95
|
+
const newVal = def.fn(model);
|
|
96
|
+
if (!isEqual(priv.computedCache[key], newVal)) {
|
|
97
|
+
priv.computedCache[key] = newVal;
|
|
98
|
+
if (!silent) {
|
|
99
|
+
(model.changed as any)[key] = newVal;
|
|
100
|
+
model.trigger('change:' + key, model, newVal, options);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
38
105
|
/**
|
|
39
106
|
* @public
|
|
40
107
|
* **Models** are the basic data object in the framework --
|
|
@@ -43,9 +110,6 @@ import {
|
|
|
43
110
|
* performing computations and transformations on that data.
|
|
44
111
|
*/
|
|
45
112
|
export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmitterObject {
|
|
46
|
-
#computedCache?: Record<string, any>;
|
|
47
|
-
#computedDefs?: Record<string, ComputedProperty<this>>;
|
|
48
|
-
#state: 'constructing' | 'hydrating' | 'ready' = 'constructing';
|
|
49
113
|
_storage?: PersistentStorage;
|
|
50
114
|
_changing = false;
|
|
51
115
|
_pending: boolean | ModelOptions = false;
|
|
@@ -67,6 +131,7 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
67
131
|
*/
|
|
68
132
|
constructor(attributes?: Partial<T>, options?: ModelOptions) {
|
|
69
133
|
super();
|
|
134
|
+
const priv = internals(this);
|
|
70
135
|
let attrs: Partial<T> = attributes || ({} as Partial<T>);
|
|
71
136
|
options = options || {};
|
|
72
137
|
|
|
@@ -82,12 +147,13 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
82
147
|
|
|
83
148
|
this.set(attrs, options);
|
|
84
149
|
|
|
85
|
-
|
|
150
|
+
initComputed(this, priv);
|
|
86
151
|
|
|
87
152
|
this.attrs = new Proxy(this.attributes as T, {
|
|
88
153
|
get: (_target, key) => {
|
|
89
|
-
|
|
90
|
-
|
|
154
|
+
const cache = priv.computedCache;
|
|
155
|
+
if (typeof key !== 'symbol' && cache && (key as string) in cache) {
|
|
156
|
+
return cache[key as string];
|
|
91
157
|
}
|
|
92
158
|
return (this.attributes as any)[key];
|
|
93
159
|
},
|
|
@@ -108,14 +174,14 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
108
174
|
// otherwise it's already ready and resolves immediately.
|
|
109
175
|
if (this.autoSync) {
|
|
110
176
|
if (getStorage(this) && !this.isNew()) {
|
|
111
|
-
|
|
112
|
-
this.hydrated =
|
|
177
|
+
priv.state = 'hydrating';
|
|
178
|
+
this.hydrated = hydrate(this, priv);
|
|
113
179
|
} else {
|
|
114
|
-
|
|
180
|
+
priv.state = 'ready';
|
|
115
181
|
this.hydrated = Promise.resolve();
|
|
116
182
|
}
|
|
117
183
|
} else {
|
|
118
|
-
|
|
184
|
+
priv.state = 'ready';
|
|
119
185
|
}
|
|
120
186
|
}
|
|
121
187
|
|
|
@@ -160,19 +226,6 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
160
226
|
return 100;
|
|
161
227
|
}
|
|
162
228
|
|
|
163
|
-
async #hydrate(): Promise<void> {
|
|
164
|
-
ensureUnloadListener(PersistentStorage);
|
|
165
|
-
try {
|
|
166
|
-
await this.fetch({ fromStorage: true, promise: true });
|
|
167
|
-
} finally {
|
|
168
|
-
this.#state = 'ready';
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
#doAutoSave(): unknown {
|
|
173
|
-
return this.save(null, { noAutoSave: true });
|
|
174
|
-
}
|
|
175
|
-
|
|
176
229
|
/**
|
|
177
230
|
* The default name for the JSON `id` attribute is `"id"`. MongoDB and
|
|
178
231
|
* CouchDB users may want to set this to `"_id"` (by overriding this getter
|
|
@@ -225,27 +278,6 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
225
278
|
return {};
|
|
226
279
|
}
|
|
227
280
|
|
|
228
|
-
#updateComputed(key: string, def: ComputedProperty<this>, silent: boolean, options: ModelOptions): void {
|
|
229
|
-
const newVal = def.fn(this);
|
|
230
|
-
if (!isEqual(this.#computedCache[key], newVal)) {
|
|
231
|
-
this.#computedCache[key] = newVal;
|
|
232
|
-
if (!silent) {
|
|
233
|
-
(this.changed as any)[key] = newVal;
|
|
234
|
-
this.trigger('change:' + key, this, newVal, options);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
#initComputed(): void {
|
|
240
|
-
const defs = result(this, 'computed') as ComputedProperties<this>;
|
|
241
|
-
if (!defs || isEmpty(defs)) return;
|
|
242
|
-
this.#computedDefs = defs;
|
|
243
|
-
this.#computedCache = {};
|
|
244
|
-
for (const [key, def] of Object.entries(defs)) {
|
|
245
|
-
this.#computedCache[key] = def.fn(this);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
281
|
/**
|
|
250
282
|
* Return a copy of the model's `attributes` object.
|
|
251
283
|
*/
|
|
@@ -264,8 +296,9 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
264
296
|
* Get the value of an attribute, or a computed property.
|
|
265
297
|
*/
|
|
266
298
|
get<K extends keyof T>(attr: K): T[K] {
|
|
267
|
-
|
|
268
|
-
|
|
299
|
+
const cache = internals(this).computedCache;
|
|
300
|
+
if (cache && (attr as string) in cache) {
|
|
301
|
+
return cache[attr as string];
|
|
269
302
|
}
|
|
270
303
|
return this.attributes[attr];
|
|
271
304
|
}
|
|
@@ -317,10 +350,19 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
317
350
|
}
|
|
318
351
|
|
|
319
352
|
/**
|
|
320
|
-
*
|
|
353
|
+
* Returns `true` if the model's attributes satisfy `attrs`.
|
|
354
|
+
*
|
|
355
|
+
* An attributes object is compared with lodash's `isMatch`: a partial, deep
|
|
356
|
+
* comparison, so `{ form: { type: 'hidden' } }` matches a `form` attribute
|
|
357
|
+
* that has `type: 'hidden'` alongside other keys. A function is called with
|
|
358
|
+
* the attributes and its result coerced to a boolean.
|
|
359
|
+
*
|
|
360
|
+
* To scan a collection, prefer `Collection#find`, `filter` or `where`. They
|
|
361
|
+
* build their matcher once per scan instead of once per model.
|
|
321
362
|
*/
|
|
322
|
-
matches(attrs: Partial<T>): boolean {
|
|
323
|
-
return !!
|
|
363
|
+
matches(attrs: Partial<T> | ((attrs: T) => any)): boolean {
|
|
364
|
+
if (typeof attrs === 'function') return !!attrs(this.attributes);
|
|
365
|
+
return isMatch(this.attributes, attrs);
|
|
324
366
|
}
|
|
325
367
|
|
|
326
368
|
/**
|
|
@@ -342,10 +384,11 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
342
384
|
}
|
|
343
385
|
|
|
344
386
|
options = options || {};
|
|
387
|
+
const priv = internals(this);
|
|
345
388
|
|
|
346
389
|
// Guard must come after arg normalization: when called as set(obj, opts),
|
|
347
390
|
// fromStorage lives in `val` (second arg), not `options` (third arg).
|
|
348
|
-
if (this.autoSync &&
|
|
391
|
+
if (this.autoSync && priv.state === 'hydrating' && !options.fromStorage) {
|
|
349
392
|
throw new Error(
|
|
350
393
|
`Skeletor: set() called on an autoSync model before hydration resolved. Await model.hydrated first.`,
|
|
351
394
|
);
|
|
@@ -394,10 +437,10 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
394
437
|
}
|
|
395
438
|
|
|
396
439
|
// Recalculate computed properties whose deps changed.
|
|
397
|
-
if (changes.length &&
|
|
398
|
-
for (const [key, def] of Object.entries(
|
|
440
|
+
if (changes.length && priv.computedDefs) {
|
|
441
|
+
for (const [key, def] of Object.entries(priv.computedDefs)) {
|
|
399
442
|
if (def.deps.some((dep) => changes.includes(dep))) {
|
|
400
|
-
|
|
443
|
+
updateComputed(this, priv, key, def, silent, options);
|
|
401
444
|
}
|
|
402
445
|
}
|
|
403
446
|
}
|
|
@@ -420,14 +463,14 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
420
463
|
// were actual changes that originated from application code (not a
|
|
421
464
|
// storage read or an explicit noAutoSave call).
|
|
422
465
|
if (
|
|
423
|
-
|
|
466
|
+
priv.state === 'ready' &&
|
|
424
467
|
this.autoSync &&
|
|
425
468
|
getStorage(this) &&
|
|
426
469
|
changes.length &&
|
|
427
470
|
!options.fromStorage &&
|
|
428
471
|
!options.noAutoSave
|
|
429
472
|
) {
|
|
430
|
-
scheduleAutoSave(this, () => this
|
|
473
|
+
scheduleAutoSave(this, () => this.save(null, { noAutoSave: true }), this.autoSyncDelay);
|
|
431
474
|
}
|
|
432
475
|
|
|
433
476
|
return this;
|
|
@@ -511,48 +554,26 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
511
554
|
const success = options.success;
|
|
512
555
|
const promise = options.promise ? getResolveablePromise() : undefined;
|
|
513
556
|
|
|
514
|
-
// Tracks whether sync invoked one of the callbacks. A sync may instead
|
|
515
|
-
// settle its returned promise; the `.then`/`.catch` below bridges that to
|
|
516
|
-
// the callbacks so the fetch promise never hangs. The flag is set
|
|
517
|
-
// synchronously inside each callback (before sync's promise resolves), so
|
|
518
|
-
// it's reliable by the time those handlers run.
|
|
519
|
-
let settled = false;
|
|
520
|
-
|
|
521
557
|
options.success = (resp: any) => {
|
|
522
|
-
settled = true;
|
|
523
558
|
const serverAttrs = options.parse ? this.parse(resp, options) : resp;
|
|
524
559
|
// fromStorage prevents the set() from triggering auto-save on reads
|
|
525
|
-
if (!this.set(serverAttrs, Object.assign({}, options, { fromStorage: true })))
|
|
560
|
+
if (!this.set(serverAttrs, Object.assign({}, options, { fromStorage: true }))) {
|
|
561
|
+
// The fetched attributes failed validation (`'invalid'` has fired).
|
|
562
|
+
// Settle the promise with the error rather than leave it pending.
|
|
563
|
+
promise && promise.reject(this.validationError);
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
526
566
|
if (success) success.call(options.context, this, resp, options);
|
|
527
567
|
this.trigger('sync', this, resp, options);
|
|
528
568
|
promise && promise.resolve(resp);
|
|
529
569
|
};
|
|
530
570
|
|
|
531
571
|
wrapError(this, options, promise);
|
|
532
|
-
if (promise) {
|
|
533
|
-
const onError = options.error;
|
|
534
|
-
options.error = (resp: any) => {
|
|
535
|
-
settled = true;
|
|
536
|
-
onError(resp);
|
|
537
|
-
};
|
|
538
|
-
}
|
|
539
572
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
// treat the resolved value as the response, so the model is still
|
|
545
|
-
// merged and the promise settles instead of hanging.
|
|
546
|
-
(value) => {
|
|
547
|
-
if (!settled) options.success(value);
|
|
548
|
-
},
|
|
549
|
-
// A sync that rejects instead of invoking options.error; settling
|
|
550
|
-
// twice is a harmless no-op.
|
|
551
|
-
(e) => promise.reject(e),
|
|
552
|
-
);
|
|
553
|
-
return promise;
|
|
554
|
-
}
|
|
555
|
-
return result;
|
|
573
|
+
// With `promise`, the sync's outcome is bridged to it however the sync
|
|
574
|
+
// reports it: through the callbacks, or through the promise it returns.
|
|
575
|
+
const result = bridgeSyncToPromise(options, promise, () => this.sync('read', this, options));
|
|
576
|
+
return promise ?? result;
|
|
556
577
|
}
|
|
557
578
|
|
|
558
579
|
/**
|
|
@@ -574,15 +595,19 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
574
595
|
options = Object.assign({ validate: true, parse: true }, options);
|
|
575
596
|
const wait = options.wait;
|
|
576
597
|
const return_promise = options.promise;
|
|
577
|
-
const promise = return_promise
|
|
598
|
+
const promise = return_promise ? getResolveablePromise() : undefined;
|
|
578
599
|
|
|
579
600
|
// If we're not waiting and attributes exist, save acts as
|
|
580
601
|
// `set(attr).save(null, opts)` with validation. Otherwise, check if
|
|
581
602
|
// the model will be valid when the attributes, if any, are set.
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
603
|
+
const valid = attrs && !wait ? !!this.set(attrs, options) : this._validate(attrs || {}, options);
|
|
604
|
+
if (!valid) {
|
|
605
|
+
// Nothing is synced (`'invalid'` has fired). With `promise`, hand back a
|
|
606
|
+
// rejected promise rather than `false`, so the promise contract holds
|
|
607
|
+
// for every outcome.
|
|
608
|
+
if (!promise) return false;
|
|
609
|
+
promise.reject(this.validationError);
|
|
610
|
+
return promise;
|
|
586
611
|
}
|
|
587
612
|
|
|
588
613
|
// After a successful server-side save, the client is (optionally)
|
|
@@ -596,7 +621,12 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
596
621
|
this.attributes = attributes;
|
|
597
622
|
let serverAttrs = options.parse ? this.parse(resp, options) : resp;
|
|
598
623
|
if (wait) serverAttrs = Object.assign({}, attrs, serverAttrs);
|
|
599
|
-
if (serverAttrs && !this.set(serverAttrs, options))
|
|
624
|
+
if (serverAttrs && !this.set(serverAttrs, options)) {
|
|
625
|
+
// The server attributes failed validation (`'invalid'` has fired).
|
|
626
|
+
// Settle the promise with the error rather than leave it pending.
|
|
627
|
+
return_promise && promise.reject(this.validationError);
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
600
630
|
if (success) success.call(options.context, this, resp, options);
|
|
601
631
|
this.trigger('sync', this, resp, options);
|
|
602
632
|
return_promise && promise.resolve();
|
|
@@ -614,16 +644,14 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
614
644
|
|
|
615
645
|
const method = this.isNew() ? 'create' : options.patch ? 'patch' : 'update';
|
|
616
646
|
if (method === 'patch' && !options.attrs) options.attrs = attrs;
|
|
617
|
-
|
|
647
|
+
// With `promise`, the sync's outcome is bridged to it however the sync
|
|
648
|
+
// reports it: through the callbacks, or through the promise it returns.
|
|
649
|
+
const xhr = bridgeSyncToPromise(options, promise, () => this.sync(method, this, options));
|
|
618
650
|
|
|
619
651
|
// Restore attributes.
|
|
620
652
|
this.attributes = attributes;
|
|
621
653
|
|
|
622
|
-
|
|
623
|
-
return promise;
|
|
624
|
-
} else {
|
|
625
|
-
return xhr;
|
|
626
|
-
}
|
|
654
|
+
return promise ?? xhr;
|
|
627
655
|
}
|
|
628
656
|
|
|
629
657
|
/**
|