@converse/skeletor 3.0.1 → 3.1.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/README.md +330 -38
- package/dist/skeletor.d.ts +200 -56
- package/dist/skeletor.esm.js +747 -457
- package/dist/skeletor.esm.js.map +1 -1
- package/dist/skeletor.js +748 -456
- package/dist/skeletor.js.map +1 -1
- package/dist/skeletor.min.js +6 -19
- package/dist/skeletor.node.js +12507 -0
- package/dist/skeletor.node.mjs +12494 -0
- package/package.json +27 -10
- package/src/autosync.ts +156 -0
- package/src/collection.ts +140 -35
- package/src/drivers/helpers.ts +61 -0
- package/src/drivers/nodeSQLiteStorage.ts +162 -0
- package/src/drivers/sessionStorage.ts +16 -22
- package/src/drivers/types.ts +25 -0
- package/src/eventemitter.ts +9 -0
- package/src/helpers.ts +57 -10
- package/src/index.node.ts +16 -0
- package/src/index.ts +11 -3
- package/src/model.ts +254 -13
- package/src/storage.ts +93 -36
- package/src/types.ts +34 -9
- package/src/utils/events.ts +6 -6
package/package.json
CHANGED
|
@@ -9,9 +9,18 @@
|
|
|
9
9
|
"browser": "dist/skeletor.js",
|
|
10
10
|
"exports": {
|
|
11
11
|
".": {
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
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"
|
|
15
24
|
}
|
|
16
25
|
},
|
|
17
26
|
"keywords": [
|
|
@@ -27,24 +36,28 @@
|
|
|
27
36
|
"contributors": [
|
|
28
37
|
"JC Brand"
|
|
29
38
|
],
|
|
30
|
-
"version": "3.
|
|
39
|
+
"version": "3.1.1",
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22.0.0"
|
|
42
|
+
},
|
|
31
43
|
"license": "MIT",
|
|
32
44
|
"repository": {
|
|
33
45
|
"type": "git",
|
|
34
46
|
"url": "https://github.com/conversejs/skeletor.git"
|
|
35
47
|
},
|
|
36
48
|
"scripts": {
|
|
37
|
-
"clean": "rm -rf dist",
|
|
38
|
-
"prebuild": "npm run clean && mkdir -p dist/defs",
|
|
39
49
|
"build": "npm run prebuild && npm run build:declarations && rollup -c",
|
|
40
|
-
"build:types": "tsc -p tsconfig.declarations.json",
|
|
41
50
|
"build:declarations": "npm run build:types && api-extractor run --local && rm -rf dist/defs",
|
|
42
|
-
"
|
|
51
|
+
"build:types": "tsc -p tsconfig.declarations.json",
|
|
52
|
+
"check": "npm run check:types && npm run test && npm run test:node && npm run lint",
|
|
43
53
|
"check:types": "tsc --noEmit",
|
|
54
|
+
"clean": "rm -rf dist",
|
|
44
55
|
"dev": "karma start --single-run=false",
|
|
45
56
|
"lint": "eslint src/*.ts test/*.ts",
|
|
57
|
+
"prebuild": "npm run clean && mkdir -p dist/defs",
|
|
46
58
|
"prettier": "prettier --write src/**/*.js test/**/*.js src/**/*.ts test/**/*.ts",
|
|
47
|
-
"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'"
|
|
48
61
|
},
|
|
49
62
|
"files": [
|
|
50
63
|
"LICENSE",
|
|
@@ -53,6 +66,8 @@
|
|
|
53
66
|
"dist/skeletor.js.map",
|
|
54
67
|
"dist/skeletor.esm.js.map",
|
|
55
68
|
"dist/skeletor.min.js",
|
|
69
|
+
"dist/skeletor.node.js",
|
|
70
|
+
"dist/skeletor.node.mjs",
|
|
56
71
|
"dist/skeletor.d.ts",
|
|
57
72
|
"dist/skeletor.d.ts.map",
|
|
58
73
|
"src/"
|
|
@@ -65,7 +80,8 @@
|
|
|
65
80
|
"localforage-driver-memory": "^1.0.5",
|
|
66
81
|
"localforage-setitems": "^1.4.0",
|
|
67
82
|
"lodash-es": "^4.17.21",
|
|
68
|
-
"mergebounce": "0.1.3"
|
|
83
|
+
"mergebounce": "0.1.3",
|
|
84
|
+
"tslib": "^2.8.1"
|
|
69
85
|
},
|
|
70
86
|
"devDependencies": {
|
|
71
87
|
"@babel/core": "^7.10.2",
|
|
@@ -102,6 +118,7 @@
|
|
|
102
118
|
"rollup-plugin-summary": "^3.0.0",
|
|
103
119
|
"sinon": "^9.0.2",
|
|
104
120
|
"ts-loader": "^9.5.4",
|
|
121
|
+
"tsx": "^4.21.0",
|
|
105
122
|
"typescript": "^5.8.3",
|
|
106
123
|
"webpack": "^5.98.0",
|
|
107
124
|
"webpack-cli": "^6.0.1",
|
package/src/autosync.ts
ADDED
|
@@ -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
|
|
10
|
-
import {getResolveablePromise, getSyncMethod, wrapError} from './helpers';
|
|
11
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|