@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/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 iteratee from 'lodash-es/iteratee';
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
- this.#initComputed();
150
+ initComputed(this, priv);
86
151
 
87
152
  this.attrs = new Proxy(this.attributes as T, {
88
153
  get: (_target, key) => {
89
- if (typeof key !== 'symbol' && this.#computedCache && (key as string) in this.#computedCache) {
90
- return this.#computedCache[key as string];
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
- this.#state = 'hydrating';
112
- this.hydrated = this.#hydrate();
177
+ priv.state = 'hydrating';
178
+ this.hydrated = hydrate(this, priv);
113
179
  } else {
114
- this.#state = 'ready';
180
+ priv.state = 'ready';
115
181
  this.hydrated = Promise.resolve();
116
182
  }
117
183
  } else {
118
- this.#state = 'ready';
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
- if (this.#computedCache && (attr as string) in this.#computedCache) {
268
- return this.#computedCache[attr as string];
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
- * Special-cased proxy to lodash's `matches` method.
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 !!iteratee(attrs)(this.attributes);
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 && this.#state === 'hydrating' && !options.fromStorage) {
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 && this.#computedDefs) {
398
- for (const [key, def] of Object.entries(this.#computedDefs)) {
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
- this.#updateComputed(key, def, silent, options);
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
- this.#state === 'ready' &&
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.#doAutoSave(), this.autoSyncDelay);
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 }))) return false;
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
- const result = this.sync('read', this, options);
541
- if (promise) {
542
- Promise.resolve(result).then(
543
- // A sync that resolves its promise without invoking options.success:
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 && getResolveablePromise();
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
- if (attrs && !wait) {
583
- if (!this.set(attrs, options)) return false;
584
- } else if (!this._validate(attrs || {}, options)) {
585
- return false;
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)) return false;
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
- const xhr = this.sync(method, this, options);
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
- if (return_promise) {
623
- return promise;
624
- } else {
625
- return xhr;
626
- }
654
+ return promise ?? xhr;
627
655
  }
628
656
 
629
657
  /**