@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/package.json CHANGED
@@ -39,7 +39,7 @@
39
39
  "contributors": [
40
40
  "JC Brand"
41
41
  ],
42
- "version": "3.1.6",
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 prebuild && npm run build:declarations && rollup -c",
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.4.4",
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": "^10.2.0",
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": "^5.8.3",
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 { getResolveablePromise, getStorage, getSyncMethod, warnBrowserStorageDeprecation, wrapError } from './helpers';
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) ? this.#hydrate() : Promise.resolve();
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.map((m) => m.attributes).every(pred as (attrs: ModelAttributes) => boolean);
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.map((m) => m.attributes).some(pred as (attrs: ModelAttributes) => boolean);
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, isFunction(f) ? f : (m) => (isString(f) ? m.get(f) : m.matches(f)));
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, isFunction(pred) ? pred : (m) => (isString(pred) ? m.get(pred) : m.matches(pred)));
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
- const pred = isFunction(predicate)
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
- promise && promise.resolve();
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
- const result = this.sync('read', this, options);
656
- if (promise) {
657
- Promise.resolve(result).then(
658
- // A sync that resolves its promise without invoking options.success:
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 (!preparedModel) return null;
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
- preparedModel.save(null, Object.assign(options, { 'promise': false }));
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
- * Override this function to change the manner in which Backbone persists
187
- * models to the server. You will be passed the type of request, and the
188
- * model in question. By default makes a `fetch()` API call
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
- const type = methodMap[method];
209
- const params: RequestInit & { success?: any; error?: any } = {
210
- method: type,
211
- body: data ? JSON.stringify(data) : '',
212
- headers: {
213
- 'Content-Type': 'application/json',
214
- },
215
- success: options.success,
216
- error: options.error,
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
- if (options) {
222
- options.xhr = xhr;
223
- }
312
+ options.xhr = xhr;
224
313
  model.trigger('request', model, xhr, { ...params, xhr });
225
- return xhr;
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';