@converse/skeletor 3.1.6 → 4.0.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/CHANGES.md +50 -0
- package/dist/skeletor.d.ts +36 -14
- package/dist/skeletor.esm.js +291 -174
- package/dist/skeletor.esm.js.map +1 -1
- package/dist/skeletor.js +291 -173
- package/dist/skeletor.js.map +1 -1
- package/dist/skeletor.min.js +4 -4
- package/dist/skeletor.node.d.ts +36 -14
- package/dist/skeletor.node.js +291 -173
- package/dist/skeletor.node.mjs +291 -174
- package/package.json +1 -1
- package/src/collection.ts +68 -78
- package/src/helpers.ts +135 -25
- package/src/index.ts +4 -2
- package/src/model.ts +43 -48
package/package.json
CHANGED
package/src/collection.ts
CHANGED
|
@@ -4,10 +4,18 @@ import groupBy from 'lodash-es/groupBy';
|
|
|
4
4
|
import isFunction from 'lodash-es/isFunction';
|
|
5
5
|
import isString from 'lodash-es/isString';
|
|
6
6
|
import keyBy from 'lodash-es/keyBy';
|
|
7
|
+
import matches from 'lodash-es/matches';
|
|
7
8
|
import sortBy from 'lodash-es/sortBy';
|
|
8
9
|
import { EventEmitterObject } from './eventemitter';
|
|
9
10
|
import PersistentStorage from './storage';
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
bridgeSyncToPromise,
|
|
13
|
+
getResolveablePromise,
|
|
14
|
+
getStorage,
|
|
15
|
+
getSyncMethod,
|
|
16
|
+
warnBrowserStorageDeprecation,
|
|
17
|
+
wrapError,
|
|
18
|
+
} from './helpers';
|
|
11
19
|
import { ensureUnloadListener } from './autosync';
|
|
12
20
|
import { Model } from './model';
|
|
13
21
|
import {
|
|
@@ -26,6 +34,24 @@ import {
|
|
|
26
34
|
const setOptions = { add: true, remove: true, merge: true };
|
|
27
35
|
const addOptions = { add: true, remove: false };
|
|
28
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Normalizes the predicate forms accepted by the query methods into one
|
|
39
|
+
* `(model) => value` function. A function is used as-is, a string reads that
|
|
40
|
+
* attribute, and an attributes object becomes a lodash `matches` matcher.
|
|
41
|
+
*
|
|
42
|
+
* The matcher is built here, once per scan. Calling `Model#matches` per model
|
|
43
|
+
* instead rebuilds the match data for every model visited, which made
|
|
44
|
+
* `findWhere` an order of magnitude slower than a hand-written predicate.
|
|
45
|
+
*/
|
|
46
|
+
function toModelPredicate<T extends Model>(
|
|
47
|
+
pred: ((model: T) => any) | string | Partial<ModelAttributes>,
|
|
48
|
+
): (model: T) => any {
|
|
49
|
+
if (isFunction(pred)) return pred as (model: T) => any;
|
|
50
|
+
if (isString(pred)) return (m: T) => m.get(pred);
|
|
51
|
+
const matcher = matches(pred);
|
|
52
|
+
return (m: T) => matcher(m.attributes);
|
|
53
|
+
}
|
|
54
|
+
|
|
29
55
|
/**
|
|
30
56
|
* @public
|
|
31
57
|
* If models tend to represent a single row of data, a Collection is
|
|
@@ -394,20 +420,14 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
394
420
|
}
|
|
395
421
|
|
|
396
422
|
filter(callback: ((model: T) => boolean) | string | Partial<ModelAttributes>, thisArg?: any): T[] {
|
|
397
|
-
return this.models.filter(
|
|
398
|
-
isFunction(callback)
|
|
399
|
-
? (callback as (model: T) => boolean)
|
|
400
|
-
: (m) => m.matches(callback as Partial<ModelAttributes>),
|
|
401
|
-
thisArg,
|
|
402
|
-
);
|
|
423
|
+
return this.models.filter(toModelPredicate(callback), thisArg);
|
|
403
424
|
}
|
|
404
425
|
|
|
405
426
|
every(pred: ((attrs: ModelAttributes) => boolean) | Options): boolean {
|
|
406
427
|
if (isFunction(pred)) {
|
|
407
|
-
return this.models.
|
|
408
|
-
} else {
|
|
409
|
-
return this.models.every((m) => m.matches(pred));
|
|
428
|
+
return this.models.every((m) => (pred as (attrs: ModelAttributes) => boolean)(m.attributes));
|
|
410
429
|
}
|
|
430
|
+
return this.models.every(toModelPredicate(pred));
|
|
411
431
|
}
|
|
412
432
|
|
|
413
433
|
difference(values: T[]): T[] {
|
|
@@ -428,19 +448,13 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
428
448
|
|
|
429
449
|
some(pred: ((attrs: ModelAttributes) => boolean) | Options): boolean {
|
|
430
450
|
if (isFunction(pred)) {
|
|
431
|
-
return this.models.
|
|
432
|
-
} else {
|
|
433
|
-
return this.models.some((m) => m.matches(pred));
|
|
451
|
+
return this.models.some((m) => (pred as (attrs: ModelAttributes) => boolean)(m.attributes));
|
|
434
452
|
}
|
|
453
|
+
return this.models.some(toModelPredicate(pred));
|
|
435
454
|
}
|
|
436
455
|
|
|
437
|
-
sortBy(iteratee: string | ((model: T) => any)): T[] {
|
|
438
|
-
return sortBy(
|
|
439
|
-
this.models,
|
|
440
|
-
isFunction(iteratee)
|
|
441
|
-
? iteratee
|
|
442
|
-
: (m: T) => (isString(iteratee) ? m.get(iteratee as string) : m.matches(iteratee as Partial<ModelAttributes>)),
|
|
443
|
-
);
|
|
456
|
+
sortBy(iteratee: string | ((model: T) => any) | Partial<ModelAttributes>): T[] {
|
|
457
|
+
return sortBy(this.models, toModelPredicate(iteratee));
|
|
444
458
|
}
|
|
445
459
|
|
|
446
460
|
isEmpty(): boolean {
|
|
@@ -468,11 +482,11 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
468
482
|
}
|
|
469
483
|
|
|
470
484
|
countBy(f: string | ((model: T) => string) | Partial<ModelAttributes>): Record<string, number> {
|
|
471
|
-
return countBy(this.models,
|
|
485
|
+
return countBy(this.models, toModelPredicate(f));
|
|
472
486
|
}
|
|
473
487
|
|
|
474
|
-
groupBy(pred: string | ((model: T) => string | number)): Record<string, T[]> {
|
|
475
|
-
return groupBy(this.models,
|
|
488
|
+
groupBy(pred: string | ((model: T) => string | number) | Partial<ModelAttributes>): Record<string, T[]> {
|
|
489
|
+
return groupBy(this.models, toModelPredicate(pred));
|
|
476
490
|
}
|
|
477
491
|
|
|
478
492
|
indexOf(model: T, fromIndex?: number): number {
|
|
@@ -480,12 +494,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
480
494
|
}
|
|
481
495
|
|
|
482
496
|
findLastIndex(pred: ((model: T) => boolean) | string | Partial<ModelAttributes>, fromIndex?: number): number {
|
|
483
|
-
return this.models.findLastIndex(
|
|
484
|
-
isFunction(pred)
|
|
485
|
-
? (pred as (model: T) => boolean)
|
|
486
|
-
: (m) => (isString(pred) ? m.get(pred as string) : m.matches(pred as Partial<ModelAttributes>)),
|
|
487
|
-
fromIndex,
|
|
488
|
-
);
|
|
497
|
+
return this.models.findLastIndex(toModelPredicate(pred), fromIndex);
|
|
489
498
|
}
|
|
490
499
|
|
|
491
500
|
lastIndexOf(model: T, fromIndex?: number): number {
|
|
@@ -493,11 +502,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
493
502
|
}
|
|
494
503
|
|
|
495
504
|
findIndex(pred: ((model: T) => boolean) | string | Partial<ModelAttributes>): number {
|
|
496
|
-
return this.models.findIndex(
|
|
497
|
-
isFunction(pred)
|
|
498
|
-
? (pred as (model: T) => boolean)
|
|
499
|
-
: (m) => (isString(pred) ? m.get(pred as string) : m.matches(pred as Partial<ModelAttributes>)),
|
|
500
|
-
);
|
|
505
|
+
return this.models.findIndex(toModelPredicate(pred));
|
|
501
506
|
}
|
|
502
507
|
|
|
503
508
|
last(): T | undefined {
|
|
@@ -514,12 +519,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
514
519
|
}
|
|
515
520
|
|
|
516
521
|
map<U>(cb: string | ((model: T) => U) | Partial<ModelAttributes>, thisArg?: any): U[] {
|
|
517
|
-
return this.models.map(
|
|
518
|
-
isFunction(cb)
|
|
519
|
-
? (cb as (model: T) => U)
|
|
520
|
-
: (m) => (isString(cb) ? m.get(cb as string) : m.matches(cb as Partial<ModelAttributes>)),
|
|
521
|
-
thisArg,
|
|
522
|
-
);
|
|
522
|
+
return this.models.map(toModelPredicate(cb), thisArg);
|
|
523
523
|
}
|
|
524
524
|
|
|
525
525
|
reduce<U = T>(callback: (accumulator: U, model: T, index: number, array: T[]) => U, initialValue?: U): U | T {
|
|
@@ -579,10 +579,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
579
579
|
}
|
|
580
580
|
|
|
581
581
|
find(predicate: ((model: T) => boolean) | Partial<ModelAttributes> | string, fromIndex?: number): T | undefined {
|
|
582
|
-
|
|
583
|
-
? (predicate as (model: T) => boolean)
|
|
584
|
-
: (m: T) => m.matches(predicate as Partial<ModelAttributes>);
|
|
585
|
-
return this.models.find(pred, fromIndex);
|
|
582
|
+
return this.models.find(toModelPredicate(predicate), fromIndex);
|
|
586
583
|
}
|
|
587
584
|
|
|
588
585
|
/**
|
|
@@ -627,47 +624,22 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
627
624
|
const collection = this;
|
|
628
625
|
const promise = options.promise ? getResolveablePromise() : undefined;
|
|
629
626
|
|
|
630
|
-
// Tracks whether sync invoked one of the callbacks. A sync may instead
|
|
631
|
-
// settle its returned promise; the `.then`/`.catch` below bridges that to
|
|
632
|
-
// the callbacks so the fetch promise never hangs. The flag is set
|
|
633
|
-
// synchronously inside each callback (before sync's promise resolves), so
|
|
634
|
-
// it's reliable by the time those handlers run.
|
|
635
|
-
let settled = false;
|
|
636
|
-
|
|
637
627
|
options.success = function (resp: any) {
|
|
638
|
-
settled = true;
|
|
639
628
|
const method = options.reset ? 'reset' : 'set';
|
|
640
629
|
// fromStorage prevents individual model set() calls from triggering auto-save on reads
|
|
641
630
|
collection[method](resp, Object.assign({}, options, { fromStorage: true }));
|
|
642
631
|
if (success) success.call(options.context, collection, resp, options);
|
|
643
|
-
|
|
632
|
+
// Fire 'sync' before resolving, as `Model.fetch`/`save` do, so a throwing
|
|
633
|
+
// listener rejects the promise instead of being lost.
|
|
644
634
|
collection.trigger('sync', collection, resp, options);
|
|
635
|
+
promise && promise.resolve();
|
|
645
636
|
};
|
|
646
637
|
wrapError(this, options, promise);
|
|
647
|
-
if (promise) {
|
|
648
|
-
const onError = options.error;
|
|
649
|
-
options.error = (resp: any) => {
|
|
650
|
-
settled = true;
|
|
651
|
-
onError(resp);
|
|
652
|
-
};
|
|
653
|
-
}
|
|
654
638
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
// treat the resolved value as the response, so the collection is still
|
|
660
|
-
// populated and the promise settles instead of hanging.
|
|
661
|
-
(value) => {
|
|
662
|
-
if (!settled) options.success(value);
|
|
663
|
-
},
|
|
664
|
-
// A sync that rejects instead of invoking options.error; settling
|
|
665
|
-
// twice is a harmless no-op.
|
|
666
|
-
(e) => promise.reject(e),
|
|
667
|
-
);
|
|
668
|
-
return promise;
|
|
669
|
-
}
|
|
670
|
-
return result;
|
|
639
|
+
// With `promise`, the sync's outcome is bridged to it however the sync
|
|
640
|
+
// reports it: through the callbacks, or through the promise it returns.
|
|
641
|
+
const result = bridgeSyncToPromise(options, promise, () => this.sync('read', this, options));
|
|
642
|
+
return promise ?? result;
|
|
671
643
|
}
|
|
672
644
|
|
|
673
645
|
/**
|
|
@@ -681,8 +653,19 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
681
653
|
const return_promise = options.promise;
|
|
682
654
|
const promise = return_promise && getResolveablePromise();
|
|
683
655
|
|
|
656
|
+
// `_prepareModel` reports attributes that fail validation by firing
|
|
657
|
+
// `'invalid'` and returning null. With `promise`, capture that error so a
|
|
658
|
+
// rejected promise is handed back rather than `null`.
|
|
659
|
+
let validationError: any;
|
|
660
|
+
const captureInvalid = (_c: unknown, err: any) => (validationError = err);
|
|
661
|
+
if (return_promise) this.on('invalid', captureInvalid);
|
|
684
662
|
const preparedModel = this._prepareModel(model, options);
|
|
685
|
-
if (
|
|
663
|
+
if (return_promise) this.off('invalid', captureInvalid);
|
|
664
|
+
if (!preparedModel) {
|
|
665
|
+
if (!return_promise) return null;
|
|
666
|
+
promise.reject(validationError);
|
|
667
|
+
return promise;
|
|
668
|
+
}
|
|
686
669
|
|
|
687
670
|
if (!wait) this.add(preparedModel, options);
|
|
688
671
|
|
|
@@ -706,8 +689,15 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
706
689
|
return_promise && promise.reject(e);
|
|
707
690
|
};
|
|
708
691
|
|
|
709
|
-
|
|
692
|
+
// `options.promise` is passed through, so `save` installs its own bridge
|
|
693
|
+
// from the sync's returned promise to its callbacks: a sync that resolves
|
|
694
|
+
// without calling them still reaches the wired `options.success` above,
|
|
695
|
+
// which resolves our promise with the model. Only a rejection that bypassed
|
|
696
|
+
// the callbacks (a rejecting sync, or a throwing `'sync'`/`'error'`
|
|
697
|
+
// listener) still needs bridging to ours.
|
|
698
|
+
const result = preparedModel.save(null, options);
|
|
710
699
|
if (return_promise) {
|
|
700
|
+
Promise.resolve(result).catch((e) => promise.reject(e));
|
|
711
701
|
return promise;
|
|
712
702
|
} else {
|
|
713
703
|
return preparedModel;
|
package/src/helpers.ts
CHANGED
|
@@ -28,6 +28,15 @@ export function guid(): string {
|
|
|
28
28
|
// Helpers
|
|
29
29
|
// -------
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Whether `value` is a thenable (a promise, or a promise-like object such as
|
|
33
|
+
* a jqXHR). Decides whether a `sync` return value can be bridged to the
|
|
34
|
+
* promise that `fetch`/`save` hand back.
|
|
35
|
+
*/
|
|
36
|
+
export function isThenable(value: any): value is PromiseLike<any> {
|
|
37
|
+
return value != null && typeof value.then === 'function';
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
// Helper function to correctly set up the prototype chain for subclasses.
|
|
32
41
|
// Similar to `goog.inherits`, but uses a hash of prototype properties and
|
|
33
42
|
// class properties to be extended.
|
|
@@ -131,6 +140,68 @@ export function wrapError(
|
|
|
131
140
|
};
|
|
132
141
|
}
|
|
133
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Run a `sync` and make sure `promise` settles from its outcome, however the
|
|
145
|
+
* sync reports it.
|
|
146
|
+
*
|
|
147
|
+
* A `sync` may invoke `options.success`/`options.error` (the callback
|
|
148
|
+
* contract), or settle the promise it returns without calling them: a
|
|
149
|
+
* promise-only custom sync, or an async sync whose promise rejects because a
|
|
150
|
+
* `'sync'`/`'error'` listener threw after the callbacks ran. `options.success`
|
|
151
|
+
* and `options.error` must already be wired to resolve/reject `promise`. This
|
|
152
|
+
* wraps them to record that they ran, calls `run()`, and bridges a thenable
|
|
153
|
+
* result to them so the promise never hangs:
|
|
154
|
+
*
|
|
155
|
+
* - fulfilment with the callbacks unrun is delivered to `options.success`, so
|
|
156
|
+
* the response is still merged into the model or collection;
|
|
157
|
+
* - rejection with the callbacks unrun is delivered to `options.error`, so the
|
|
158
|
+
* caller's `error` callback and the `'error'` event still fire;
|
|
159
|
+
* - rejection after the callbacks ran rejects `promise` directly: a no-op if
|
|
160
|
+
* they already settled it, and the only way to settle it if a listener threw
|
|
161
|
+
* before they could;
|
|
162
|
+
* - a throw from either delivery (a throwing `success`/`error` callback or
|
|
163
|
+
* listener) rejects `promise` instead of surfacing as an unhandled rejection.
|
|
164
|
+
*
|
|
165
|
+
* A non-thenable result (a callback-only sync that returns nothing, or an
|
|
166
|
+
* XHR) is left alone: such a sync settles `promise` through its callbacks, and
|
|
167
|
+
* bridging it would fire `success` on the next microtask, before the sync is
|
|
168
|
+
* done. Without a `promise`, `run()` is called as is.
|
|
169
|
+
*/
|
|
170
|
+
export function bridgeSyncToPromise(options: any, promise: ResolveablePromise | undefined, run: () => any): any {
|
|
171
|
+
if (!promise) return run();
|
|
172
|
+
|
|
173
|
+
let settled = false;
|
|
174
|
+
const success = options.success;
|
|
175
|
+
const error = options.error;
|
|
176
|
+
options.success = (...args: any[]) => {
|
|
177
|
+
settled = true;
|
|
178
|
+
return success && success(...args);
|
|
179
|
+
};
|
|
180
|
+
options.error = (...args: any[]) => {
|
|
181
|
+
settled = true;
|
|
182
|
+
return error && error(...args);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const result = run();
|
|
186
|
+
if (isThenable(result)) {
|
|
187
|
+
Promise.resolve(result)
|
|
188
|
+
.then(
|
|
189
|
+
(value) => {
|
|
190
|
+
if (!settled) options.success(value);
|
|
191
|
+
},
|
|
192
|
+
(e) => {
|
|
193
|
+
if (settled) {
|
|
194
|
+
promise.reject(e);
|
|
195
|
+
} else {
|
|
196
|
+
options.error(e);
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
.catch((e) => promise.reject(e));
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
134
205
|
// Map from CRUD to HTTP for our default `sync` implementation.
|
|
135
206
|
const methodMap: Record<string, string> = {
|
|
136
207
|
create: 'POST',
|
|
@@ -183,21 +254,42 @@ export function getSyncMethod(model: Model | Collection<any>): typeof sync & { _
|
|
|
183
254
|
}
|
|
184
255
|
|
|
185
256
|
/**
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* to the model's `url()`.
|
|
190
|
-
*
|
|
191
|
-
* Some possible customizations could be:
|
|
192
|
-
*
|
|
193
|
-
* - Use `setTimeout` to batch rapid-fire updates into a single request.
|
|
194
|
-
* - Persist models via WebSockets instead of Ajax.
|
|
195
|
-
* - Persist models to browser storage
|
|
257
|
+
* The error the default `sync` hands to `options.error`, and rejects with, for
|
|
258
|
+
* a response outside the 2xx range.
|
|
259
|
+
* @public
|
|
196
260
|
*/
|
|
261
|
+
export class HTTPError extends Error {
|
|
262
|
+
readonly status: number;
|
|
263
|
+
readonly response: Response;
|
|
264
|
+
|
|
265
|
+
constructor(response: Response, method: string, url: string) {
|
|
266
|
+
const statusText = response.statusText ? ` ${response.statusText}` : '';
|
|
267
|
+
super(`${method} ${url} failed with HTTP ${response.status}${statusText}`);
|
|
268
|
+
this.name = 'HTTPError';
|
|
269
|
+
this.status = response.status;
|
|
270
|
+
this.response = response;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
197
274
|
/**
|
|
275
|
+
* The default `sync`: a JSON request over `fetch()` to the model's `url()`.
|
|
276
|
+
* Override it (or `Model.sync`/`Collection.sync`) to change how models are
|
|
277
|
+
* persisted, for instance to batch rapid-fire updates with `setTimeout`, to
|
|
278
|
+
* talk over WebSockets, or to write to browser storage.
|
|
279
|
+
*
|
|
280
|
+
* It honours both contracts a `sync` may use. On a 2xx response the JSON body
|
|
281
|
+
* (`null` when the body is empty) is passed to `options.success` and the
|
|
282
|
+
* returned promise resolves with it. On a non-2xx response, a network failure
|
|
283
|
+
* or an unparsable body, `options.error` receives the error (an `HTTPError`
|
|
284
|
+
* for the first) and the promise rejects with it.
|
|
285
|
+
*
|
|
286
|
+
* `options.data` is sent as query parameters on GET, which may not carry a
|
|
287
|
+
* body, and as a JSON body otherwise. For create, update and patch the body
|
|
288
|
+
* defaults to `options.attrs` or the model's `toJSON()`.
|
|
198
289
|
* @public
|
|
199
290
|
*/
|
|
200
291
|
export function sync(method: SyncOperation, model: Model | Collection<any>, options: SyncOptions = {}): Promise<any> {
|
|
292
|
+
const type = methodMap[method];
|
|
201
293
|
let data = options.data;
|
|
202
294
|
|
|
203
295
|
// Ensure that we have the appropriate request data.
|
|
@@ -205,22 +297,40 @@ export function sync(method: SyncOperation, model: Model | Collection<any>, opti
|
|
|
205
297
|
data = options.attrs || model.toJSON();
|
|
206
298
|
}
|
|
207
299
|
|
|
208
|
-
|
|
209
|
-
const params: RequestInit
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
300
|
+
let url: string = options.url || result(model, 'url') || urlError();
|
|
301
|
+
const params: RequestInit = { method: type };
|
|
302
|
+
if (data != null) {
|
|
303
|
+
if (type === 'GET') {
|
|
304
|
+
url += (url.includes('?') ? '&' : '?') + new URLSearchParams(data).toString();
|
|
305
|
+
} else {
|
|
306
|
+
params.body = JSON.stringify(data);
|
|
307
|
+
params.headers = { 'Content-Type': 'application/json' };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
218
310
|
|
|
219
|
-
const url = options.url || result(model, 'url') || urlError();
|
|
220
311
|
const xhr = fetch(url, params);
|
|
221
|
-
|
|
222
|
-
options.xhr = xhr;
|
|
223
|
-
}
|
|
312
|
+
options.xhr = xhr;
|
|
224
313
|
model.trigger('request', model, xhr, { ...params, xhr });
|
|
225
|
-
|
|
314
|
+
|
|
315
|
+
return Promise.resolve(xhr)
|
|
316
|
+
.then(async (response) => {
|
|
317
|
+
if (!response.ok) throw new HTTPError(response, type, url);
|
|
318
|
+
const text = await response.text();
|
|
319
|
+
return text ? JSON.parse(text) : null;
|
|
320
|
+
})
|
|
321
|
+
.then(
|
|
322
|
+
// The callbacks run before the returned promise settles, so a caller that
|
|
323
|
+
// bridges this promise (`Model.fetch`/`save`) sees the outcome as already
|
|
324
|
+
// reported. A throw from `success` (a `'sync'` listener, say) is not
|
|
325
|
+
// routed to `error`: it rejects the promise, which is what the bridge
|
|
326
|
+
// expects once the callbacks have run.
|
|
327
|
+
(resp) => {
|
|
328
|
+
options.success?.(resp, options);
|
|
329
|
+
return resp;
|
|
330
|
+
},
|
|
331
|
+
(e) => {
|
|
332
|
+
options.error?.(e);
|
|
333
|
+
throw e;
|
|
334
|
+
},
|
|
335
|
+
);
|
|
226
336
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { EventEmitter } from './eventemitter';
|
|
|
3
3
|
import PersistentStorage from './storage';
|
|
4
4
|
import { Collection } from './collection';
|
|
5
5
|
import { Model } from './model';
|
|
6
|
-
import { sync } from './helpers';
|
|
6
|
+
import { sync, HTTPError } from './helpers';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* @public
|
|
@@ -13,6 +13,7 @@ interface SkeletorType {
|
|
|
13
13
|
EventEmitter: typeof EventEmitter;
|
|
14
14
|
Model: typeof Model;
|
|
15
15
|
sync: typeof sync;
|
|
16
|
+
HTTPError: typeof HTTPError;
|
|
16
17
|
VERSION?: string;
|
|
17
18
|
noConflict?: () => SkeletorType;
|
|
18
19
|
}
|
|
@@ -25,6 +26,7 @@ const skeletor: SkeletorType = {
|
|
|
25
26
|
EventEmitter,
|
|
26
27
|
Model,
|
|
27
28
|
sync,
|
|
29
|
+
HTTPError,
|
|
28
30
|
};
|
|
29
31
|
|
|
30
32
|
// Establish the root object, `window` (`self`) in the browser, or `global` on the server.
|
|
@@ -59,7 +61,7 @@ export default skeletor;
|
|
|
59
61
|
* @public
|
|
60
62
|
*/
|
|
61
63
|
const BrowserStorage = PersistentStorage;
|
|
62
|
-
export { noConflict, Collection, EventEmitter, Model, PersistentStorage, BrowserStorage, sync };
|
|
64
|
+
export { noConflict, Collection, EventEmitter, Model, PersistentStorage, BrowserStorage, sync, HTTPError };
|
|
63
65
|
export { getStorage } from './helpers';
|
|
64
66
|
|
|
65
67
|
export type { StorageDriver, StoreType } from './drivers/types';
|
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';
|
|
@@ -317,10 +318,19 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
317
318
|
}
|
|
318
319
|
|
|
319
320
|
/**
|
|
320
|
-
*
|
|
321
|
+
* Returns `true` if the model's attributes satisfy `attrs`.
|
|
322
|
+
*
|
|
323
|
+
* An attributes object is compared with lodash's `isMatch`: a partial, deep
|
|
324
|
+
* comparison, so `{ form: { type: 'hidden' } }` matches a `form` attribute
|
|
325
|
+
* that has `type: 'hidden'` alongside other keys. A function is called with
|
|
326
|
+
* the attributes and its result coerced to a boolean.
|
|
327
|
+
*
|
|
328
|
+
* To scan a collection, prefer `Collection#find`, `filter` or `where`. They
|
|
329
|
+
* build their matcher once per scan instead of once per model.
|
|
321
330
|
*/
|
|
322
|
-
matches(attrs: Partial<T>): boolean {
|
|
323
|
-
return !!
|
|
331
|
+
matches(attrs: Partial<T> | ((attrs: T) => any)): boolean {
|
|
332
|
+
if (typeof attrs === 'function') return !!attrs(this.attributes);
|
|
333
|
+
return isMatch(this.attributes, attrs);
|
|
324
334
|
}
|
|
325
335
|
|
|
326
336
|
/**
|
|
@@ -511,48 +521,26 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
511
521
|
const success = options.success;
|
|
512
522
|
const promise = options.promise ? getResolveablePromise() : undefined;
|
|
513
523
|
|
|
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
524
|
options.success = (resp: any) => {
|
|
522
|
-
settled = true;
|
|
523
525
|
const serverAttrs = options.parse ? this.parse(resp, options) : resp;
|
|
524
526
|
// fromStorage prevents the set() from triggering auto-save on reads
|
|
525
|
-
if (!this.set(serverAttrs, Object.assign({}, options, { fromStorage: true })))
|
|
527
|
+
if (!this.set(serverAttrs, Object.assign({}, options, { fromStorage: true }))) {
|
|
528
|
+
// The fetched attributes failed validation (`'invalid'` has fired).
|
|
529
|
+
// Settle the promise with the error rather than leave it pending.
|
|
530
|
+
promise && promise.reject(this.validationError);
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
526
533
|
if (success) success.call(options.context, this, resp, options);
|
|
527
534
|
this.trigger('sync', this, resp, options);
|
|
528
535
|
promise && promise.resolve(resp);
|
|
529
536
|
};
|
|
530
537
|
|
|
531
538
|
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
539
|
|
|
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;
|
|
540
|
+
// With `promise`, the sync's outcome is bridged to it however the sync
|
|
541
|
+
// reports it: through the callbacks, or through the promise it returns.
|
|
542
|
+
const result = bridgeSyncToPromise(options, promise, () => this.sync('read', this, options));
|
|
543
|
+
return promise ?? result;
|
|
556
544
|
}
|
|
557
545
|
|
|
558
546
|
/**
|
|
@@ -574,15 +562,19 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
574
562
|
options = Object.assign({ validate: true, parse: true }, options);
|
|
575
563
|
const wait = options.wait;
|
|
576
564
|
const return_promise = options.promise;
|
|
577
|
-
const promise = return_promise
|
|
565
|
+
const promise = return_promise ? getResolveablePromise() : undefined;
|
|
578
566
|
|
|
579
567
|
// If we're not waiting and attributes exist, save acts as
|
|
580
568
|
// `set(attr).save(null, opts)` with validation. Otherwise, check if
|
|
581
569
|
// the model will be valid when the attributes, if any, are set.
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
570
|
+
const valid = attrs && !wait ? !!this.set(attrs, options) : this._validate(attrs || {}, options);
|
|
571
|
+
if (!valid) {
|
|
572
|
+
// Nothing is synced (`'invalid'` has fired). With `promise`, hand back a
|
|
573
|
+
// rejected promise rather than `false`, so the promise contract holds
|
|
574
|
+
// for every outcome.
|
|
575
|
+
if (!promise) return false;
|
|
576
|
+
promise.reject(this.validationError);
|
|
577
|
+
return promise;
|
|
586
578
|
}
|
|
587
579
|
|
|
588
580
|
// After a successful server-side save, the client is (optionally)
|
|
@@ -596,7 +588,12 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
596
588
|
this.attributes = attributes;
|
|
597
589
|
let serverAttrs = options.parse ? this.parse(resp, options) : resp;
|
|
598
590
|
if (wait) serverAttrs = Object.assign({}, attrs, serverAttrs);
|
|
599
|
-
if (serverAttrs && !this.set(serverAttrs, options))
|
|
591
|
+
if (serverAttrs && !this.set(serverAttrs, options)) {
|
|
592
|
+
// The server attributes failed validation (`'invalid'` has fired).
|
|
593
|
+
// Settle the promise with the error rather than leave it pending.
|
|
594
|
+
return_promise && promise.reject(this.validationError);
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
600
597
|
if (success) success.call(options.context, this, resp, options);
|
|
601
598
|
this.trigger('sync', this, resp, options);
|
|
602
599
|
return_promise && promise.resolve();
|
|
@@ -614,16 +611,14 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
|
|
|
614
611
|
|
|
615
612
|
const method = this.isNew() ? 'create' : options.patch ? 'patch' : 'update';
|
|
616
613
|
if (method === 'patch' && !options.attrs) options.attrs = attrs;
|
|
617
|
-
|
|
614
|
+
// With `promise`, the sync's outcome is bridged to it however the sync
|
|
615
|
+
// reports it: through the callbacks, or through the promise it returns.
|
|
616
|
+
const xhr = bridgeSyncToPromise(options, promise, () => this.sync(method, this, options));
|
|
618
617
|
|
|
619
618
|
// Restore attributes.
|
|
620
619
|
this.attributes = attributes;
|
|
621
620
|
|
|
622
|
-
|
|
623
|
-
return promise;
|
|
624
|
-
} else {
|
|
625
|
-
return xhr;
|
|
626
|
-
}
|
|
621
|
+
return promise ?? xhr;
|
|
627
622
|
}
|
|
628
623
|
|
|
629
624
|
/**
|