@converse/skeletor 3.1.5 → 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/package.json CHANGED
@@ -9,18 +9,19 @@
9
9
  "browser": "dist/skeletor.js",
10
10
  "exports": {
11
11
  ".": {
12
- "types": "./dist/skeletor.d.ts",
13
12
  "node": {
13
+ "types": "./dist/skeletor.node.d.ts",
14
14
  "require": "./dist/skeletor.node.js",
15
15
  "import": "./dist/skeletor.node.mjs"
16
16
  },
17
17
  "default": {
18
+ "types": "./dist/skeletor.d.ts",
18
19
  "import": "./dist/skeletor.esm.js",
19
20
  "require": "./dist/skeletor.js"
20
21
  }
21
22
  },
22
23
  "./node": {
23
- "types": "./dist/skeletor.d.ts",
24
+ "types": "./dist/skeletor.node.d.ts",
24
25
  "require": "./dist/skeletor.node.js",
25
26
  "import": "./dist/skeletor.node.mjs"
26
27
  }
@@ -38,7 +39,7 @@
38
39
  "contributors": [
39
40
  "JC Brand"
40
41
  ],
41
- "version": "3.1.5",
42
+ "version": "4.0.0",
42
43
  "engines": {
43
44
  "node": ">=22.0.0"
44
45
  },
@@ -49,7 +50,7 @@
49
50
  },
50
51
  "scripts": {
51
52
  "build": "npm run prebuild && npm run build:declarations && rollup -c",
52
- "build:declarations": "npm run build:types && api-extractor run --local && rm -rf dist/defs",
53
+ "build:declarations": "npm run build:types && api-extractor run --local && api-extractor run --local -c api-extractor.node.json && rm -rf dist/defs",
53
54
  "build:types": "tsc -p tsconfig.declarations.json",
54
55
  "check": "npm run check:types && npm run test && npm run test:node && npm run lint",
55
56
  "check:types": "tsc --noEmit",
@@ -74,6 +75,7 @@
74
75
  "dist/skeletor.node.mjs",
75
76
  "dist/skeletor.d.ts",
76
77
  "dist/skeletor.d.ts.map",
78
+ "dist/skeletor.node.d.ts",
77
79
  "src/**/*.ts"
78
80
  ],
79
81
  "dependencies": {
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,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.map((m) => m.attributes).every(pred as (attrs: ModelAttributes) => boolean);
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.map((m) => m.attributes).some(pred as (attrs: ModelAttributes) => boolean);
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, isFunction(f) ? f : (m) => (isString(f) ? m.get(f) : m.matches(f)));
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, isFunction(pred) ? pred : (m) => (isString(pred) ? m.get(pred) : m.matches(pred)));
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
- 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);
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
- promise && promise.resolve();
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
- 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;
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 (!preparedModel) return null;
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
- preparedModel.save(null, Object.assign(options, { 'promise': false }));
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;
@@ -9,6 +9,7 @@ const DEFAULT_BASE_DIR = '.skeletor-storage';
9
9
  /**
10
10
  * SQLite-backed implementation of StorageDriver.
11
11
  * Uses a single table with key/value columns, storing values as JSON strings.
12
+ * @public
12
13
  */
13
14
  export class NodeSQLiteStorage implements StorageDriver {
14
15
  private db: DatabaseSync;
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,10 +61,11 @@ 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';
68
+ export type { NodeStorageConstructor } from './storage';
66
69
  export type {
67
70
  ClassConstructor,
68
71
  CollectionOptions,