@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/package.json
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"contributors": [
|
|
40
40
|
"JC Brand"
|
|
41
41
|
],
|
|
42
|
-
"version": "
|
|
42
|
+
"version": "4.0.1",
|
|
43
43
|
"engines": {
|
|
44
44
|
"node": ">=22.0.0"
|
|
45
45
|
},
|
|
@@ -49,15 +49,12 @@
|
|
|
49
49
|
"url": "https://github.com/conversejs/skeletor.git"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
|
-
"build": "npm run
|
|
53
|
-
"build:declarations": "npm run build:types && api-extractor run --local && api-extractor run --local -c api-extractor.node.json && rm -rf dist/defs",
|
|
54
|
-
"build:types": "tsc -p tsconfig.declarations.json",
|
|
52
|
+
"build": "npm run clean && rollup -c",
|
|
55
53
|
"check": "npm run check:types && npm run test && npm run test:node && npm run lint",
|
|
56
54
|
"check:types": "tsc --noEmit",
|
|
57
55
|
"clean": "rm -rf dist",
|
|
58
56
|
"dev": "karma start --single-run=false",
|
|
59
57
|
"lint": "eslint src/*.ts test/*.ts",
|
|
60
|
-
"prebuild": "npm run clean && mkdir -p dist/defs",
|
|
61
58
|
"prepack": "npm run build",
|
|
62
59
|
"prettier": "prettier --write src/**/*.js test/**/*.js src/**/*.ts test/**/*.ts",
|
|
63
60
|
"test": "karma start && karma start --mocha-only",
|
|
@@ -74,7 +71,6 @@
|
|
|
74
71
|
"dist/skeletor.node.js",
|
|
75
72
|
"dist/skeletor.node.mjs",
|
|
76
73
|
"dist/skeletor.d.ts",
|
|
77
|
-
"dist/skeletor.d.ts.map",
|
|
78
74
|
"dist/skeletor.node.d.ts",
|
|
79
75
|
"src/**/*.ts"
|
|
80
76
|
],
|
|
@@ -95,14 +91,14 @@
|
|
|
95
91
|
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
|
|
96
92
|
"@babel/plugin-proposal-optional-chaining": "^7.13.12",
|
|
97
93
|
"@babel/preset-env": "^7.9.5",
|
|
98
|
-
"@microsoft/api-extractor": "^7.53.2",
|
|
99
94
|
"@rollup/plugin-babel": "^6.0.4",
|
|
100
95
|
"@rollup/plugin-commonjs": "^28.0.3",
|
|
101
96
|
"@rollup/plugin-node-resolve": "^16.0.1",
|
|
102
|
-
"@rollup/plugin-terser": "^0.
|
|
97
|
+
"@rollup/plugin-terser": "^1.0.0",
|
|
103
98
|
"@rollup/plugin-typescript": "^12.1.4",
|
|
104
99
|
"@types/chai": "^5.2.3",
|
|
105
100
|
"@types/mocha": "^10.0.10",
|
|
101
|
+
"@types/node": "^26.6.5",
|
|
106
102
|
"@types/qunit": "^2.19.13",
|
|
107
103
|
"@types/sinon": "^17.0.4",
|
|
108
104
|
"@typescript-eslint/eslint-plugin": "^8.29.0",
|
|
@@ -117,15 +113,16 @@
|
|
|
117
113
|
"karma-mocha": "^2.0.1",
|
|
118
114
|
"karma-qunit": "^4.1.1",
|
|
119
115
|
"karma-webpack": "^5.0.1",
|
|
120
|
-
"mocha": "^
|
|
116
|
+
"mocha": "^12.0.0",
|
|
121
117
|
"prettier": "^3.0.0",
|
|
122
118
|
"qunit": "^2.10.0",
|
|
123
119
|
"rollup": "^4.39.0",
|
|
120
|
+
"rollup-plugin-dts": "^6.5.1",
|
|
124
121
|
"rollup-plugin-summary": "^3.0.0",
|
|
125
122
|
"sinon": "^9.0.2",
|
|
126
123
|
"ts-loader": "^9.5.4",
|
|
127
124
|
"tsx": "^4.21.0",
|
|
128
|
-
"typescript": "^
|
|
125
|
+
"typescript": "^6.0.3",
|
|
129
126
|
"webpack": "^5.98.0",
|
|
130
127
|
"webpack-cli": "^6.0.1",
|
|
131
128
|
"window-or-global": "^1.0.1"
|
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,37 @@ 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
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Load the collection's members from storage. A module-level function rather
|
|
57
|
+
* than a `#private` method so the published declarations carry no private
|
|
58
|
+
* members, which TypeScript 6 rejects in anonymously extended classes (TS4094).
|
|
59
|
+
*/
|
|
60
|
+
async function hydrate(collection: Collection<any>): Promise<void> {
|
|
61
|
+
ensureUnloadListener(PersistentStorage);
|
|
62
|
+
// An empty store is a normal first-run state: findAll() returns [] and a
|
|
63
|
+
// missing model record resolves to null, so hydration resolves rather
|
|
64
|
+
// than rejecting.
|
|
65
|
+
await collection.fetch({ fromStorage: true, promise: true });
|
|
66
|
+
}
|
|
67
|
+
|
|
29
68
|
/**
|
|
30
69
|
* @public
|
|
31
70
|
* If models tend to represent a single row of data, a Collection is
|
|
@@ -65,7 +104,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
65
104
|
// uniformly `await collection.hydrated`; it resolves immediately when
|
|
66
105
|
// there's no storage to hydrate from.
|
|
67
106
|
if (this.autoSync) {
|
|
68
|
-
this.hydrated = getStorage(this) ?
|
|
107
|
+
this.hydrated = getStorage(this) ? hydrate(this) : Promise.resolve();
|
|
69
108
|
}
|
|
70
109
|
}
|
|
71
110
|
|
|
@@ -117,14 +156,6 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
117
156
|
return false;
|
|
118
157
|
}
|
|
119
158
|
|
|
120
|
-
async #hydrate(): Promise<void> {
|
|
121
|
-
ensureUnloadListener(PersistentStorage);
|
|
122
|
-
// An empty store is a normal first-run state: findAll() returns [] and a
|
|
123
|
-
// missing model record resolves to null, so hydration resolves rather
|
|
124
|
-
// than rejecting.
|
|
125
|
-
await this.fetch({ fromStorage: true, promise: true });
|
|
126
|
-
}
|
|
127
|
-
|
|
128
159
|
/**
|
|
129
160
|
* The default model for a collection is just a **Model**.
|
|
130
161
|
* This should be overridden in most cases.
|
|
@@ -394,20 +425,14 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
394
425
|
}
|
|
395
426
|
|
|
396
427
|
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
|
-
);
|
|
428
|
+
return this.models.filter(toModelPredicate(callback), thisArg);
|
|
403
429
|
}
|
|
404
430
|
|
|
405
431
|
every(pred: ((attrs: ModelAttributes) => boolean) | Options): boolean {
|
|
406
432
|
if (isFunction(pred)) {
|
|
407
|
-
return this.models.
|
|
408
|
-
} else {
|
|
409
|
-
return this.models.every((m) => m.matches(pred));
|
|
433
|
+
return this.models.every((m) => (pred as (attrs: ModelAttributes) => boolean)(m.attributes));
|
|
410
434
|
}
|
|
435
|
+
return this.models.every(toModelPredicate(pred));
|
|
411
436
|
}
|
|
412
437
|
|
|
413
438
|
difference(values: T[]): T[] {
|
|
@@ -428,19 +453,13 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
428
453
|
|
|
429
454
|
some(pred: ((attrs: ModelAttributes) => boolean) | Options): boolean {
|
|
430
455
|
if (isFunction(pred)) {
|
|
431
|
-
return this.models.
|
|
432
|
-
} else {
|
|
433
|
-
return this.models.some((m) => m.matches(pred));
|
|
456
|
+
return this.models.some((m) => (pred as (attrs: ModelAttributes) => boolean)(m.attributes));
|
|
434
457
|
}
|
|
458
|
+
return this.models.some(toModelPredicate(pred));
|
|
435
459
|
}
|
|
436
460
|
|
|
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
|
-
);
|
|
461
|
+
sortBy(iteratee: string | ((model: T) => any) | Partial<ModelAttributes>): T[] {
|
|
462
|
+
return sortBy(this.models, toModelPredicate(iteratee));
|
|
444
463
|
}
|
|
445
464
|
|
|
446
465
|
isEmpty(): boolean {
|
|
@@ -468,11 +487,11 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
468
487
|
}
|
|
469
488
|
|
|
470
489
|
countBy(f: string | ((model: T) => string) | Partial<ModelAttributes>): Record<string, number> {
|
|
471
|
-
return countBy(this.models,
|
|
490
|
+
return countBy(this.models, toModelPredicate(f));
|
|
472
491
|
}
|
|
473
492
|
|
|
474
|
-
groupBy(pred: string | ((model: T) => string | number)): Record<string, T[]> {
|
|
475
|
-
return groupBy(this.models,
|
|
493
|
+
groupBy(pred: string | ((model: T) => string | number) | Partial<ModelAttributes>): Record<string, T[]> {
|
|
494
|
+
return groupBy(this.models, toModelPredicate(pred));
|
|
476
495
|
}
|
|
477
496
|
|
|
478
497
|
indexOf(model: T, fromIndex?: number): number {
|
|
@@ -480,12 +499,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
480
499
|
}
|
|
481
500
|
|
|
482
501
|
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
|
-
);
|
|
502
|
+
return this.models.findLastIndex(toModelPredicate(pred), fromIndex);
|
|
489
503
|
}
|
|
490
504
|
|
|
491
505
|
lastIndexOf(model: T, fromIndex?: number): number {
|
|
@@ -493,11 +507,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
493
507
|
}
|
|
494
508
|
|
|
495
509
|
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
|
-
);
|
|
510
|
+
return this.models.findIndex(toModelPredicate(pred));
|
|
501
511
|
}
|
|
502
512
|
|
|
503
513
|
last(): T | undefined {
|
|
@@ -514,12 +524,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
514
524
|
}
|
|
515
525
|
|
|
516
526
|
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
|
-
);
|
|
527
|
+
return this.models.map(toModelPredicate(cb), thisArg);
|
|
523
528
|
}
|
|
524
529
|
|
|
525
530
|
reduce<U = T>(callback: (accumulator: U, model: T, index: number, array: T[]) => U, initialValue?: U): U | T {
|
|
@@ -579,10 +584,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
579
584
|
}
|
|
580
585
|
|
|
581
586
|
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);
|
|
587
|
+
return this.models.find(toModelPredicate(predicate), fromIndex);
|
|
586
588
|
}
|
|
587
589
|
|
|
588
590
|
/**
|
|
@@ -627,47 +629,22 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
627
629
|
const collection = this;
|
|
628
630
|
const promise = options.promise ? getResolveablePromise() : undefined;
|
|
629
631
|
|
|
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
632
|
options.success = function (resp: any) {
|
|
638
|
-
settled = true;
|
|
639
633
|
const method = options.reset ? 'reset' : 'set';
|
|
640
634
|
// fromStorage prevents individual model set() calls from triggering auto-save on reads
|
|
641
635
|
collection[method](resp, Object.assign({}, options, { fromStorage: true }));
|
|
642
636
|
if (success) success.call(options.context, collection, resp, options);
|
|
643
|
-
|
|
637
|
+
// Fire 'sync' before resolving, as `Model.fetch`/`save` do, so a throwing
|
|
638
|
+
// listener rejects the promise instead of being lost.
|
|
644
639
|
collection.trigger('sync', collection, resp, options);
|
|
640
|
+
promise && promise.resolve();
|
|
645
641
|
};
|
|
646
642
|
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
643
|
|
|
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;
|
|
644
|
+
// With `promise`, the sync's outcome is bridged to it however the sync
|
|
645
|
+
// reports it: through the callbacks, or through the promise it returns.
|
|
646
|
+
const result = bridgeSyncToPromise(options, promise, () => this.sync('read', this, options));
|
|
647
|
+
return promise ?? result;
|
|
671
648
|
}
|
|
672
649
|
|
|
673
650
|
/**
|
|
@@ -681,8 +658,19 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
681
658
|
const return_promise = options.promise;
|
|
682
659
|
const promise = return_promise && getResolveablePromise();
|
|
683
660
|
|
|
661
|
+
// `_prepareModel` reports attributes that fail validation by firing
|
|
662
|
+
// `'invalid'` and returning null. With `promise`, capture that error so a
|
|
663
|
+
// rejected promise is handed back rather than `null`.
|
|
664
|
+
let validationError: any;
|
|
665
|
+
const captureInvalid = (_c: unknown, err: any) => (validationError = err);
|
|
666
|
+
if (return_promise) this.on('invalid', captureInvalid);
|
|
684
667
|
const preparedModel = this._prepareModel(model, options);
|
|
685
|
-
if (
|
|
668
|
+
if (return_promise) this.off('invalid', captureInvalid);
|
|
669
|
+
if (!preparedModel) {
|
|
670
|
+
if (!return_promise) return null;
|
|
671
|
+
promise.reject(validationError);
|
|
672
|
+
return promise;
|
|
673
|
+
}
|
|
686
674
|
|
|
687
675
|
if (!wait) this.add(preparedModel, options);
|
|
688
676
|
|
|
@@ -706,8 +694,15 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
|
|
|
706
694
|
return_promise && promise.reject(e);
|
|
707
695
|
};
|
|
708
696
|
|
|
709
|
-
|
|
697
|
+
// `options.promise` is passed through, so `save` installs its own bridge
|
|
698
|
+
// from the sync's returned promise to its callbacks: a sync that resolves
|
|
699
|
+
// without calling them still reaches the wired `options.success` above,
|
|
700
|
+
// which resolves our promise with the model. Only a rejection that bypassed
|
|
701
|
+
// the callbacks (a rejecting sync, or a throwing `'sync'`/`'error'`
|
|
702
|
+
// listener) still needs bridging to ours.
|
|
703
|
+
const result = preparedModel.save(null, options);
|
|
710
704
|
if (return_promise) {
|
|
705
|
+
Promise.resolve(result).catch((e) => promise.reject(e));
|
|
711
706
|
return promise;
|
|
712
707
|
} else {
|
|
713
708
|
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';
|