@converse/skeletor 3.0.0 → 3.1.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
@@ -4,9 +4,25 @@
4
4
  "url": "https://github.com/conversejs/skeletor",
5
5
  "type": "module",
6
6
  "main": "dist/skeletor.js",
7
- "module": "src/index.ts",
7
+ "module": "dist/skeletor.esm.js",
8
8
  "types": "dist/skeletor.d.ts",
9
9
  "browser": "dist/skeletor.js",
10
+ "exports": {
11
+ ".": {
12
+ "node": {
13
+ "require": "./dist/skeletor.node.js",
14
+ "import": "./dist/skeletor.node.mjs"
15
+ },
16
+ "default": {
17
+ "import": "./dist/skeletor.esm.js",
18
+ "require": "./dist/skeletor.js"
19
+ }
20
+ },
21
+ "./node": {
22
+ "require": "./dist/skeletor.node.js",
23
+ "import": "./dist/skeletor.node.mjs"
24
+ }
25
+ },
10
26
  "keywords": [
11
27
  "model",
12
28
  "view",
@@ -20,7 +36,10 @@
20
36
  "contributors": [
21
37
  "JC Brand"
22
38
  ],
23
- "version": "3.0.0",
39
+ "version": "3.1.0",
40
+ "engines": {
41
+ "node": ">=22.0.0"
42
+ },
24
43
  "license": "MIT",
25
44
  "repository": {
26
45
  "type": "git",
@@ -32,17 +51,23 @@
32
51
  "build": "npm run prebuild && npm run build:declarations && rollup -c",
33
52
  "build:types": "tsc -p tsconfig.declarations.json",
34
53
  "build:declarations": "npm run build:types && api-extractor run --local && rm -rf dist/defs",
35
- "check": "npm run check:types && npm run test && npm run lint",
54
+ "check": "npm run check:types && npm run test && npm run test:node && npm run lint",
36
55
  "check:types": "tsc --noEmit",
37
56
  "dev": "karma start --single-run=false",
38
57
  "lint": "eslint src/*.ts test/*.ts",
39
58
  "prettier": "prettier --write src/**/*.js test/**/*.js src/**/*.ts test/**/*.ts",
40
- "test": "karma start && karma start --mocha-only"
59
+ "test": "karma start && karma start --mocha-only",
60
+ "test:node": "tsx node_modules/mocha/bin/mocha.js 'test/nodeStorage.test.ts'"
41
61
  },
42
62
  "files": [
43
63
  "LICENSE",
44
64
  "dist/skeletor.js",
65
+ "dist/skeletor.esm.js",
66
+ "dist/skeletor.js.map",
67
+ "dist/skeletor.esm.js.map",
45
68
  "dist/skeletor.min.js",
69
+ "dist/skeletor.node.js",
70
+ "dist/skeletor.node.mjs",
46
71
  "dist/skeletor.d.ts",
47
72
  "dist/skeletor.d.ts.map",
48
73
  "src/"
@@ -55,7 +80,8 @@
55
80
  "localforage-driver-memory": "^1.0.5",
56
81
  "localforage-setitems": "^1.4.0",
57
82
  "lodash-es": "^4.17.21",
58
- "mergebounce": "0.1.3"
83
+ "mergebounce": "0.1.3",
84
+ "tslib": "^2.8.1"
59
85
  },
60
86
  "devDependencies": {
61
87
  "@babel/core": "^7.10.2",
@@ -92,6 +118,7 @@
92
118
  "rollup-plugin-summary": "^3.0.0",
93
119
  "sinon": "^9.0.2",
94
120
  "ts-loader": "^9.5.4",
121
+ "tsx": "^4.21.0",
95
122
  "typescript": "^5.8.3",
96
123
  "webpack": "^5.98.0",
97
124
  "webpack-cli": "^6.0.1",
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Debounced auto-save scheduling and unload-flush for models/collections
3
+ * with autoSync enabled.
4
+ */
5
+ import type PersistentStorage from './storage';
6
+ import { IEventEmitter } from './types';
7
+
8
+ const pending = new Map<IEventEmitter, { timerId: ReturnType<typeof setTimeout>; run: () => unknown }>();
9
+ const inFlight = new Set<Promise<unknown>>();
10
+ // Per-object view of `inFlight`, so a caller (notably `Model.destroy`) can
11
+ // await the saves for a single object without blocking on unrelated writes.
12
+ const inFlightByObj = new Map<IEventEmitter, Set<Promise<unknown>>>();
13
+ let unloadListenerAttached = false;
14
+
15
+ /**
16
+ * Execute a save `run` now and track the resulting promise as in-flight until
17
+ * it settles, both globally (`inFlight`) and per object (`inFlightByObj`), so
18
+ * `flushPending({ wait: true })` can await every save and `Model.destroy` can
19
+ * await just its own.
20
+ *
21
+ * @internal
22
+ * @param obj - The owning model/collection (or `null` when the save isn't tied to one).
23
+ * A rejection is logged and re-surfaced on `obj`'s `error` event.
24
+ * Called when a debounce timer fires or a flush forces it.
25
+ */
26
+ function fireRun(obj: IEventEmitter | null, run: () => unknown): void {
27
+ const p = Promise.resolve(run()).catch((e) => {
28
+ console.error('Skeletor autoSync save error:', e);
29
+ // Surface failures that bypassed `options.error` on the model's `error` event,
30
+ // the same channel storage-layer failures already use. Storage saves route
31
+ // errors through `options.error` and then resolve, so they never reach this
32
+ // catch and won't double-emit.
33
+ obj?.trigger?.('error', obj, e);
34
+ });
35
+ inFlight.add(p);
36
+ let perObj: Set<Promise<unknown>> | undefined;
37
+ if (obj) {
38
+ perObj = inFlightByObj.get(obj);
39
+ if (!perObj) {
40
+ perObj = new Set();
41
+ inFlightByObj.set(obj, perObj);
42
+ }
43
+ perObj.add(p);
44
+ }
45
+ p.finally(() => {
46
+ inFlight.delete(p);
47
+ if (perObj) {
48
+ perObj.delete(p);
49
+ if (perObj.size === 0) inFlightByObj.delete(obj!);
50
+ }
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Schedule a debounced auto-save for `obj`. Replaces any pending schedule
56
+ * for the same object.
57
+ * @internal
58
+ */
59
+ export function scheduleAutoSave(obj: IEventEmitter, run: () => unknown, delay: number): void {
60
+ const existing = pending.get(obj);
61
+ if (existing) clearTimeout(existing.timerId);
62
+ const timerId = setTimeout(() => {
63
+ pending.delete(obj);
64
+ fireRun(obj, run);
65
+ }, delay);
66
+ pending.set(obj, { timerId, run });
67
+ }
68
+
69
+ /**
70
+ * Cancel any pending auto-save for `obj`.
71
+ *
72
+ * This only stops a *pending* (not-yet-fired) debounce timer. A save whose
73
+ * timer has already fired is in flight and beyond cancellation — sequence
74
+ * against it with {@link awaitInFlightSaves} instead.
75
+ * @internal
76
+ */
77
+ export function cancelAutoSave(obj: IEventEmitter): void {
78
+ const existing = pending.get(obj);
79
+ if (existing) {
80
+ clearTimeout(existing.timerId);
81
+ pending.delete(obj);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Return a promise that settles once every in-flight auto-save for `obj` has
87
+ * completed, or `null` when none are in flight. `Model.destroy` uses this to
88
+ * sequence its delete after a save that has already fired, so a late write
89
+ * cannot land after the delete and resurrect the record.
90
+ * @internal
91
+ */
92
+ export function awaitInFlightSaves(obj: IEventEmitter): Promise<void> | null {
93
+ const set = inFlightByObj.get(obj);
94
+ if (!set || set.size === 0) return null;
95
+ return Promise.all([...set]).then(() => undefined);
96
+ }
97
+
98
+ /**
99
+ * Immediately execute all pending auto-saves and flush storage-level
100
+ * debounced write buffers.
101
+ *
102
+ * Pass `wait: true` to also await all in-flight saves before resolving — use
103
+ * this in tests instead of arbitrary `setTimeout` sleeps.
104
+ *
105
+ * The default (`wait: false`) is synchronous and is what the unload handler
106
+ * uses, since the browser gives no way to await a write while the page is
107
+ * tearing down. It *initiates* every pending write synchronously (buffering
108
+ * into the storage-level batch and flushing it), giving the browser its best
109
+ * chance to persist them.
110
+ * @internal
111
+ */
112
+ export function flushPending(opts?: { storage?: typeof PersistentStorage; wait?: false }): void;
113
+ export function flushPending(opts: { storage?: typeof PersistentStorage; wait: true }): Promise<void>;
114
+ export function flushPending({
115
+ storage,
116
+ wait = false,
117
+ }: { storage?: typeof PersistentStorage; wait?: boolean } = {}): void | Promise<void> {
118
+ for (const [obj, { timerId, run }] of pending.entries()) {
119
+ clearTimeout(timerId);
120
+ fireRun(obj, run);
121
+ }
122
+ pending.clear();
123
+ if (storage) storage.flushAll();
124
+ if (wait) return Promise.all([...inFlight]).then(() => undefined);
125
+ }
126
+
127
+ /**
128
+ * Reset all module-level state. For use in test afterEach hooks only.
129
+ * @internal
130
+ */
131
+ export function resetForTesting(): void {
132
+ for (const { timerId } of pending.values()) clearTimeout(timerId);
133
+ pending.clear();
134
+ inFlight.clear();
135
+ inFlightByObj.clear();
136
+ unloadListenerAttached = false;
137
+ }
138
+
139
+ /**
140
+ * Lazily attach pagehide/visibilitychange listeners in browser environments
141
+ * to flush pending writes when the page is hidden/unloading. This is a
142
+ * best-effort durability measure: the flush initiates writes synchronously but
143
+ * cannot guarantee an asynchronous backend finishes persisting them before the
144
+ * page goes away. Called at most once.
145
+ * @internal
146
+ */
147
+ export function ensureUnloadListener(storage: typeof PersistentStorage): void {
148
+ if (unloadListenerAttached || typeof window === 'undefined') return;
149
+ unloadListenerAttached = true;
150
+
151
+ const flush = () => flushPending({ storage });
152
+ window.addEventListener('pagehide', flush);
153
+ window.addEventListener('visibilitychange', () => {
154
+ if (document.visibilityState === 'hidden') flush();
155
+ });
156
+ }
package/src/collection.ts CHANGED
@@ -5,13 +5,15 @@ import isFunction from 'lodash-es/isFunction';
5
5
  import isString from 'lodash-es/isString';
6
6
  import keyBy from 'lodash-es/keyBy';
7
7
  import sortBy from 'lodash-es/sortBy';
8
- import {EventEmitterObject} from './eventemitter';
9
- import type Storage from './storage';
10
- import {getResolveablePromise, getSyncMethod, wrapError} from './helpers';
11
- import {Model} from './model';
8
+ import { EventEmitterObject } from './eventemitter';
9
+ import PersistentStorage from './storage';
10
+ import { getResolveablePromise, getStorage, getSyncMethod, warnBrowserStorageDeprecation, wrapError } from './helpers';
11
+ import { ensureUnloadListener } from './autosync';
12
+ import { Model } from './model';
12
13
  import {
13
14
  CollectionOptions,
14
15
  Comparator,
16
+ EventCallback,
15
17
  FetchOrCreateOptions,
16
18
  ModelAttributes,
17
19
  ModelOptions,
@@ -21,8 +23,8 @@ import {
21
23
  } from './types';
22
24
 
23
25
  // Default options for `Collection#set`.
24
- const setOptions = {add: true, remove: true, merge: true};
25
- const addOptions = {add: true, remove: false};
26
+ const setOptions = { add: true, remove: true, merge: true };
27
+ const addOptions = { add: true, remove: false };
26
28
 
27
29
  /**
28
30
  * @public
@@ -35,9 +37,10 @@ const addOptions = {add: true, remove: false};
35
37
  */
36
38
  export class Collection<T extends Model = Model> extends EventEmitterObject {
37
39
  [key: symbol]: () => CollectionIterator<T>;
38
- _browserStorage?: Storage;
40
+ _storage?: PersistentStorage;
39
41
  _comparator?: Comparator<T>;
40
42
  _url: string = '';
43
+ initialized?: Promise<void>;
41
44
  models: T[];
42
45
  protected _byId: Record<string, T>;
43
46
  protected _model?: new (attributes?: Partial<ModelAttributes>, options?: ModelOptions) => T;
@@ -55,9 +58,15 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
55
58
  if (options.comparator !== undefined) this.comparator = options.comparator;
56
59
  this._reset();
57
60
  this.initialize.apply(this, arguments as any);
58
- if (models) this.reset(models, Object.assign({silent: true}, options));
61
+ if (models) this.reset(models, Object.assign({ silent: true }, options));
59
62
 
60
63
  this[Symbol.iterator] = this.values;
64
+ // When autoSync is on, `initialized` is always a Promise so callers can
65
+ // uniformly `await collection.initialized`; it resolves immediately when
66
+ // there's no storage to hydrate from.
67
+ if (this.autoSync) {
68
+ this.initialized = getStorage(this) ? this.#hydrate() : Promise.resolve();
69
+ }
61
70
  }
62
71
 
63
72
  get comparator(): Comparator<T> {
@@ -68,12 +77,51 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
68
77
  this._comparator = c;
69
78
  }
70
79
 
71
- set browserStorage(storage: Storage) {
72
- this._browserStorage = storage;
80
+ /**
81
+ * The canonical storage accessor. Set to a `PersistentStorage` instance
82
+ * to enable local persistence for this collection.
83
+ */
84
+ get storage(): PersistentStorage | undefined {
85
+ return this._storage;
86
+ }
87
+
88
+ set storage(s: PersistentStorage) {
89
+ this._storage = s;
90
+ PersistentStorage.register(s);
91
+ }
92
+
93
+ /**
94
+ * @deprecated Use `storage` instead.
95
+ */
96
+ get browserStorage(): PersistentStorage | undefined {
97
+ return this._storage;
98
+ }
99
+
100
+ set browserStorage(s: PersistentStorage) {
101
+ warnBrowserStorageDeprecation(this);
102
+ this.storage = s;
103
+ }
104
+
105
+ /**
106
+ * Override to return `true` to automatically load the collection from
107
+ * storage on construction. Await `initialized` to know when hydration
108
+ * is complete.
109
+ *
110
+ * Note: collection `autoSync` is hydrate-only. Unlike `Model`, it does
111
+ * **not** schedule auto-saves when the collection changes (`add`/`remove`/
112
+ * `reset`). Persisting members is each model's responsibility — enable
113
+ * `autoSync` on the contained model class and every model writes its own
114
+ * record. A collection-level write would be redundant with that.
115
+ */
116
+ get autoSync(): boolean {
117
+ return false;
73
118
  }
74
119
 
75
- get browserStorage(): Storage | undefined {
76
- return this._browserStorage;
120
+ async #hydrate(): Promise<void> {
121
+ ensureUnloadListener(PersistentStorage);
122
+ // findAll() returns [] for an empty store, so a first-run collection
123
+ // resolves rather than erroring the way a model fetch-by-id would.
124
+ await this.fetch({ fromStorage: true, promise: true });
77
125
  }
78
126
 
79
127
  /**
@@ -132,7 +180,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
132
180
  * combination of the two.
133
181
  */
134
182
  add(models: T[] | T | ModelAttributes | ModelAttributes[], options?: Options): T | T[] {
135
- return this.set(models, Object.assign({merge: false}, options, addOptions));
183
+ return this.set(models, Object.assign({ merge: false }, options, addOptions));
136
184
  }
137
185
 
138
186
  /**
@@ -144,7 +192,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
144
192
  const modelsArray = singular ? [models] : (models as T[]).slice();
145
193
  const removed = this._removeModels(modelsArray, options);
146
194
  if (!options.silent && removed.length) {
147
- options.changes = {added: [], merged: [], removed: removed};
195
+ options.changes = { added: [], merged: [], removed: removed };
148
196
  this.trigger('update', this, options);
149
197
  }
150
198
  return singular ? removed[0] : removed;
@@ -247,7 +295,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
247
295
  }
248
296
 
249
297
  // Silently sort the collection if appropriate.
250
- if (sort) this.sort({silent: true});
298
+ if (sort) this.sort({ silent: true });
251
299
 
252
300
  // Unless silenced, it's time to fire all appropriate add/sort/update events.
253
301
  if (!options.silent) {
@@ -282,12 +330,12 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
282
330
  console.error(e);
283
331
  resolve();
284
332
  },
285
- })
333
+ }),
286
334
  );
287
335
  });
288
- })
336
+ }),
289
337
  );
290
- await this.browserStorage?.clear();
338
+ await getStorage(this)?.clear();
291
339
  this.reset();
292
340
  }
293
341
 
@@ -304,7 +352,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
304
352
  }
305
353
  options.previousModels = this.models;
306
354
  this._reset();
307
- models = this.add(models, Object.assign({silent: true}, options));
355
+ models = this.add(models, Object.assign({ silent: true }, options));
308
356
  if (!options.silent) this.trigger('reset', this, options);
309
357
  return models as T | T[];
310
358
  }
@@ -313,7 +361,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
313
361
  * Add a model to the end of the collection.
314
362
  */
315
363
  push(model: T | ModelAttributes, options?: Options): T {
316
- return this.add(model, Object.assign({at: this.length}, options)) as T;
364
+ return this.add(model, Object.assign({ at: this.length }, options)) as T;
317
365
  }
318
366
 
319
367
  /**
@@ -328,7 +376,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
328
376
  * Add a model to the beginning of the collection.
329
377
  */
330
378
  unshift(model: T | ModelAttributes, options?: Options): T {
331
- return this.add(model, Object.assign({at: 0}, options)) as T;
379
+ return this.add(model, Object.assign({ at: 0 }, options)) as T;
332
380
  }
333
381
 
334
382
  /**
@@ -349,7 +397,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
349
397
  isFunction(callback)
350
398
  ? (callback as (model: T) => boolean)
351
399
  : (m) => m.matches(callback as Partial<ModelAttributes>),
352
- thisArg
400
+ thisArg,
353
401
  );
354
402
  }
355
403
 
@@ -390,7 +438,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
390
438
  this.models,
391
439
  isFunction(iteratee)
392
440
  ? iteratee
393
- : (m: T) => (isString(iteratee) ? m.get(iteratee as string) : m.matches(iteratee as Partial<ModelAttributes>))
441
+ : (m: T) => (isString(iteratee) ? m.get(iteratee as string) : m.matches(iteratee as Partial<ModelAttributes>)),
394
442
  );
395
443
  }
396
444
 
@@ -435,7 +483,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
435
483
  isFunction(pred)
436
484
  ? (pred as (model: T) => boolean)
437
485
  : (m) => (isString(pred) ? m.get(pred as string) : m.matches(pred as Partial<ModelAttributes>)),
438
- fromIndex
486
+ fromIndex,
439
487
  );
440
488
  }
441
489
 
@@ -447,7 +495,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
447
495
  return this.models.findIndex(
448
496
  isFunction(pred)
449
497
  ? (pred as (model: T) => boolean)
450
- : (m) => (isString(pred) ? m.get(pred as string) : m.matches(pred as Partial<ModelAttributes>))
498
+ : (m) => (isString(pred) ? m.get(pred as string) : m.matches(pred as Partial<ModelAttributes>)),
451
499
  );
452
500
  }
453
501
 
@@ -469,7 +517,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
469
517
  isFunction(cb)
470
518
  ? (cb as (model: T) => U)
471
519
  : (m) => (isString(cb) ? m.get(cb as string) : m.matches(cb as Partial<ModelAttributes>)),
472
- thisArg
520
+ thisArg,
473
521
  );
474
522
  }
475
523
 
@@ -572,20 +620,53 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
572
620
  * data will be passed through the `reset` method instead of `set`.
573
621
  */
574
622
  fetch(options?: Options): Promise<any> | any {
575
- options = Object.assign({parse: true}, options);
623
+ options = Object.assign({ parse: true }, options);
576
624
  const success = options.success;
577
625
  // eslint-disable-next-line @typescript-eslint/no-this-alias
578
626
  const collection = this;
579
- const promise = options.promise && getResolveablePromise();
627
+ const promise = options.promise ? getResolveablePromise() : undefined;
628
+
629
+ // Tracks whether sync invoked one of the callbacks. A sync may instead
630
+ // settle its returned promise; the `.then`/`.catch` below bridges that to
631
+ // the callbacks so the fetch promise never hangs. The flag is set
632
+ // synchronously inside each callback (before sync's promise resolves), so
633
+ // it's reliable by the time those handlers run.
634
+ let settled = false;
635
+
580
636
  options.success = function (resp: any) {
637
+ settled = true;
581
638
  const method = options.reset ? 'reset' : 'set';
582
- collection[method](resp, options);
639
+ // fromStorage prevents individual model set() calls from triggering auto-save on reads
640
+ collection[method](resp, Object.assign({}, options, { fromStorage: true }));
583
641
  if (success) success.call(options.context, collection, resp, options);
584
642
  promise && promise.resolve();
585
643
  collection.trigger('sync', collection, resp, options);
586
644
  };
587
- wrapError(this, options);
588
- return promise ? promise : this.sync('read', this, options);
645
+ wrapError(this, options, promise);
646
+ if (promise) {
647
+ const onError = options.error;
648
+ options.error = (resp: any) => {
649
+ settled = true;
650
+ onError(resp);
651
+ };
652
+ }
653
+
654
+ const result = this.sync('read', this, options);
655
+ if (promise) {
656
+ Promise.resolve(result).then(
657
+ // A sync that resolves its promise without invoking options.success:
658
+ // treat the resolved value as the response, so the collection is still
659
+ // populated and the promise settles instead of hanging.
660
+ (value) => {
661
+ if (!settled) options.success(value);
662
+ },
663
+ // A sync that rejects instead of invoking options.error; settling
664
+ // twice is a harmless no-op.
665
+ (e) => promise.reject(e),
666
+ );
667
+ return promise;
668
+ }
669
+ return result;
589
670
  }
590
671
 
591
672
  /**
@@ -624,7 +705,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
624
705
  return_promise && promise.reject(e);
625
706
  };
626
707
 
627
- preparedModel.save(null, Object.assign(options, {'promise': false}));
708
+ preparedModel.save(null, Object.assign(options, { 'promise': false }));
628
709
  if (return_promise) {
629
710
  return promise;
630
711
  } else {
@@ -632,6 +713,30 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
632
713
  }
633
714
  }
634
715
 
716
+ /**
717
+ * Subscribe to collection changes (add, remove, reset, sort, update).
718
+ * Returns an unsubscribe function.
719
+ *
720
+ * `collection.subscribe(callback)` — fires once per operation on add/remove/reset/sort (via `update`, `reset`, `sort` events).
721
+ * `collection.subscribe(event, callback)` — subscribe to a specific event (base EventEmitter form).
722
+ *
723
+ * Compatible with React's `useSyncExternalStore` and other store-style APIs.
724
+ */
725
+ subscribe(event: string, callback: EventCallback, context?: unknown): () => void;
726
+ subscribe(callback: (collection: this) => void): () => void;
727
+ subscribe(
728
+ eventOrCallback: string | ((collection: this) => void),
729
+ callback?: EventCallback,
730
+ context?: unknown,
731
+ ): () => void {
732
+ if (typeof eventOrCallback === 'function') {
733
+ const cb = () => (eventOrCallback as (collection: this) => void)(this);
734
+ this.on('update reset sort', cb);
735
+ return () => this.off('update reset sort', cb);
736
+ }
737
+ return super.subscribe(eventOrCallback, callback, context);
738
+ }
739
+
635
740
  /**
636
741
  * **parse** converts a response into a list of models to be added to the
637
742
  * collection. The default implementation is just to pass it through.
@@ -816,7 +921,7 @@ export class CollectionIterator<T extends Model> {
816
921
  this._index++;
817
922
 
818
923
  if (!model) {
819
- return {value: undefined, done: true};
924
+ return { value: undefined, done: true };
820
925
  }
821
926
 
822
927
  // Construct a value depending on what kind of values should be iterated.
@@ -832,7 +937,7 @@ export class CollectionIterator<T extends Model> {
832
937
  value = [id, model];
833
938
  }
834
939
  }
835
- return {value, done: false};
940
+ return { value, done: false };
836
941
  }
837
942
 
838
943
  // Once exhausted, remove the reference to the collection so future
@@ -840,7 +945,7 @@ export class CollectionIterator<T extends Model> {
840
945
  this._collection = undefined;
841
946
  }
842
947
 
843
- return {value: undefined, done: true};
948
+ return { value: undefined, done: true };
844
949
  }
845
950
 
846
951
  [Symbol.iterator](): IterableIterator<any> {
@@ -0,0 +1,61 @@
1
+ export function chainCallback<T>(promise: Promise<T>, callback: ((err: any, result?: T) => void) | undefined): void {
2
+ if (callback) {
3
+ promise.then(
4
+ (result) => callback(null, result),
5
+ (err) => callback(err),
6
+ );
7
+ }
8
+ }
9
+
10
+ export function getCallback(...args: any[]): ((...args: any[]) => void) | undefined {
11
+ if (args.length && typeof args[args.length - 1] === 'function') {
12
+ return args[args.length - 1];
13
+ }
14
+ }
15
+
16
+ export function normalizeKey(key: string | number): string {
17
+ if (typeof key !== 'string') {
18
+ console.warn(`${key} used as a key, but it is not a string.`);
19
+ key = String(key);
20
+ }
21
+ return key;
22
+ }
23
+
24
+ export function serializeSync(value: any): string {
25
+ return JSON.stringify(value, (_key, val) => {
26
+ if (val instanceof Date) {
27
+ return val.toISOString();
28
+ }
29
+ return val;
30
+ });
31
+ }
32
+
33
+ export function serialize(value: any, callback: (serializedValue: string, error: any) => void): void {
34
+ try {
35
+ callback(serializeSync(value), null);
36
+ } catch (e) {
37
+ callback('', e);
38
+ }
39
+ }
40
+
41
+ export function deserialize<T>(value: string): T {
42
+ return JSON.parse(value);
43
+ }
44
+
45
+ /**
46
+ * Sanitize a user-supplied string so it is safe to use as a single path segment.
47
+ * Removes path separators, null bytes, and resolves away any `.` / `..` components.
48
+ * Throws if the result is empty.
49
+ */
50
+ export function sanitizePathSegment(segment: string): string {
51
+ if (typeof segment !== 'string' || segment.length === 0) {
52
+ throw new Error(`Storage name must be a non-empty string, got: ${JSON.stringify(segment)}`);
53
+ }
54
+ // eslint-disable-next-line no-control-regex
55
+ const cleaned = segment.replace(/\x00/g, '');
56
+ const sanitized = cleaned.replace(/[/\\]/g, '_');
57
+ if (sanitized.length === 0) {
58
+ throw new Error(`Storage name sanitized to an empty string: ${JSON.stringify(segment)}`);
59
+ }
60
+ return sanitized;
61
+ }