@converse/skeletor 4.0.0 → 4.0.2

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,25 +39,23 @@
39
39
  "contributors": [
40
40
  "JC Brand"
41
41
  ],
42
- "version": "4.0.0",
42
+ "version": "4.0.2",
43
43
  "engines": {
44
44
  "node": ">=22.0.0"
45
45
  },
46
46
  "license": "MIT",
47
47
  "repository": {
48
48
  "type": "git",
49
- "url": "https://github.com/conversejs/skeletor.git"
49
+ "url": "git+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",
55
+ "check:dts": "tsc -p tsconfig.dts.json",
57
56
  "clean": "rm -rf dist",
58
57
  "dev": "karma start --single-run=false",
59
58
  "lint": "eslint src/*.ts test/*.ts",
60
- "prebuild": "npm run clean && mkdir -p dist/defs",
61
59
  "prepack": "npm run build",
62
60
  "prettier": "prettier --write src/**/*.js test/**/*.js src/**/*.ts test/**/*.ts",
63
61
  "test": "karma start && karma start --mocha-only",
@@ -74,7 +72,6 @@
74
72
  "dist/skeletor.node.js",
75
73
  "dist/skeletor.node.mjs",
76
74
  "dist/skeletor.d.ts",
77
- "dist/skeletor.d.ts.map",
78
75
  "dist/skeletor.node.d.ts",
79
76
  "src/**/*.ts"
80
77
  ],
@@ -90,25 +87,17 @@
90
87
  "tslib": "^2.8.1"
91
88
  },
92
89
  "devDependencies": {
93
- "@babel/core": "^7.10.2",
94
- "@babel/plugin-external-helpers": "^7.10.1",
95
- "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
96
- "@babel/plugin-proposal-optional-chaining": "^7.13.12",
97
- "@babel/preset-env": "^7.9.5",
98
- "@microsoft/api-extractor": "^7.53.2",
99
- "@rollup/plugin-babel": "^6.0.4",
100
90
  "@rollup/plugin-commonjs": "^28.0.3",
101
91
  "@rollup/plugin-node-resolve": "^16.0.1",
102
- "@rollup/plugin-terser": "^0.4.4",
92
+ "@rollup/plugin-terser": "^1.0.0",
103
93
  "@rollup/plugin-typescript": "^12.1.4",
104
94
  "@types/chai": "^5.2.3",
105
95
  "@types/mocha": "^10.0.10",
96
+ "@types/node": "^26.6.5",
106
97
  "@types/qunit": "^2.19.13",
107
98
  "@types/sinon": "^17.0.4",
108
99
  "@typescript-eslint/eslint-plugin": "^8.29.0",
109
100
  "@typescript-eslint/parser": "^8.29.0",
110
- "babel-eslint": "^10.1.0",
111
- "babel-loader": "^9.2.1",
112
101
  "chai": "^4.2.0",
113
102
  "eslint": "^8.57.0",
114
103
  "karma": "^6.3.16",
@@ -117,15 +106,16 @@
117
106
  "karma-mocha": "^2.0.1",
118
107
  "karma-qunit": "^4.1.1",
119
108
  "karma-webpack": "^5.0.1",
120
- "mocha": "^10.2.0",
109
+ "mocha": "^12.0.0",
121
110
  "prettier": "^3.0.0",
122
111
  "qunit": "^2.10.0",
123
112
  "rollup": "^4.39.0",
113
+ "rollup-plugin-dts": "^6.5.1",
124
114
  "rollup-plugin-summary": "^3.0.0",
125
115
  "sinon": "^9.0.2",
126
116
  "ts-loader": "^9.5.4",
127
117
  "tsx": "^4.21.0",
128
- "typescript": "^5.8.3",
118
+ "typescript": "^6.0.3",
129
119
  "webpack": "^5.98.0",
130
120
  "webpack-cli": "^6.0.1",
131
121
  "window-or-global": "^1.0.1"
package/src/collection.ts CHANGED
@@ -52,6 +52,36 @@ function toModelPredicate<T extends Model>(
52
52
  return (m: T) => matcher(m.attributes);
53
53
  }
54
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
+
68
+ /**
69
+ * `model` and `comparator` are declared on an interface merged with the class
70
+ * rather than on the class itself, so that subclasses may override them either
71
+ * as instance properties (`model = Contact`) or as accessors (`get model()`).
72
+ * TypeScript rejects the one form overriding the other (TS2610, TS2611) only
73
+ * when the base member is declared on a class.
74
+ */
75
+ export interface Collection<T extends Model = Model> {
76
+ /**
77
+ * The model class to instantiate when raw attributes are added. Defaults to
78
+ * `Model` when unset. Typed loosely so that model classes whose constructors
79
+ * take required or narrower attributes and options fit.
80
+ */
81
+ model: new (attributes?: any, options?: any) => T;
82
+ comparator: Comparator<T>;
83
+ }
84
+
55
85
  /**
56
86
  * @public
57
87
  * If models tend to represent a single row of data, a Collection is
@@ -61,15 +91,14 @@ function toModelPredicate<T extends Model>(
61
91
  * belonging to this particular author, and so on. Collections maintain
62
92
  * indexes of their models, both in order, and for lookup by `id`.
63
93
  */
94
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging -- see the interface above
64
95
  export class Collection<T extends Model = Model> extends EventEmitterObject {
65
96
  [key: symbol]: () => CollectionIterator<T>;
66
97
  _storage?: PersistentStorage;
67
- _comparator?: Comparator<T>;
68
98
  _url: string = '';
69
99
  hydrated?: Promise<void>;
70
100
  models: T[];
71
101
  protected _byId: Record<string, T>;
72
- protected _model?: new (attributes?: Partial<ModelAttributes>, options?: ModelOptions) => T;
73
102
 
74
103
  /**
75
104
  * Create a new **Collection**, perhaps to contain a specific type of `model`.
@@ -80,7 +109,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
80
109
  super();
81
110
  options = options || {};
82
111
  this.preinitialize.apply(this, arguments as any);
83
- if (options.model) this._model = options.model;
112
+ if (options.model) this.model = options.model;
84
113
  if (options.comparator !== undefined) this.comparator = options.comparator;
85
114
  this._reset();
86
115
  this.initialize.apply(this, arguments as any);
@@ -91,18 +120,10 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
91
120
  // uniformly `await collection.hydrated`; it resolves immediately when
92
121
  // there's no storage to hydrate from.
93
122
  if (this.autoSync) {
94
- this.hydrated = getStorage(this) ? this.#hydrate() : Promise.resolve();
123
+ this.hydrated = getStorage(this) ? hydrate(this) : Promise.resolve();
95
124
  }
96
125
  }
97
126
 
98
- get comparator(): Comparator<T> {
99
- return this._comparator;
100
- }
101
-
102
- set comparator(c: Comparator<T>) {
103
- this._comparator = c;
104
- }
105
-
106
127
  /**
107
128
  * The canonical storage accessor. Set to a `PersistentStorage` instance
108
129
  * to enable local persistence for this collection.
@@ -143,26 +164,6 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
143
164
  return false;
144
165
  }
145
166
 
146
- async #hydrate(): Promise<void> {
147
- ensureUnloadListener(PersistentStorage);
148
- // An empty store is a normal first-run state: findAll() returns [] and a
149
- // missing model record resolves to null, so hydration resolves rather
150
- // than rejecting.
151
- await this.fetch({ fromStorage: true, promise: true });
152
- }
153
-
154
- /**
155
- * The default model for a collection is just a **Model**.
156
- * This should be overridden in most cases.
157
- */
158
- get model(): new (attributes?: Partial<ModelAttributes>, options?: ModelOptions) => T | Model {
159
- return this._model ?? Model;
160
- }
161
-
162
- set model(model: new (attributes?: Partial<ModelAttributes>, options?: ModelOptions) => T) {
163
- this._model = model;
164
- }
165
-
166
167
  get length(): number {
167
168
  return this.models.length;
168
169
  }
@@ -740,7 +741,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
740
741
  * Define how to uniquely identify models in the collection.
741
742
  */
742
743
  modelId(attrs: ModelAttributes): string | number | undefined {
743
- return attrs[this.model.prototype?.idAttribute || 'id'];
744
+ return attrs[(this.model ?? Model).prototype?.idAttribute || 'id'];
744
745
  }
745
746
 
746
747
  /** Get an iterator of all models in this collection. */
@@ -774,7 +775,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
774
775
  }
775
776
 
776
777
  createModel(attrs: ModelAttributes, options?: Options): T {
777
- const Klass = this.model;
778
+ const Klass = this.model ?? Model;
778
779
  return new Klass(attrs, options) as T;
779
780
  }
780
781
 
package/src/model.ts CHANGED
@@ -36,6 +36,72 @@ import {
36
36
  Options,
37
37
  } from './types';
38
38
 
39
+ /**
40
+ * Per-instance state that is private to this module.
41
+ *
42
+ * It lives in a WeakMap rather than in ES `#private` fields so that the
43
+ * published declarations carry no private members. TypeScript 6 refuses to
44
+ * emit an anonymous class type whose base has private members (TS4094), and
45
+ * that is exactly what the usual mixin pattern (`class extends Base`) over
46
+ * `Model` produces in consumers such as Converse.
47
+ */
48
+ type ModelInternals = {
49
+ state: 'constructing' | 'hydrating' | 'ready';
50
+ computedCache?: Record<string, any>;
51
+ computedDefs?: ComputedProperties<any>;
52
+ };
53
+
54
+ const INTERNALS = new WeakMap<Model<any>, ModelInternals>();
55
+
56
+ function internals(model: Model<any>): ModelInternals {
57
+ let priv = INTERNALS.get(model);
58
+ if (!priv) {
59
+ priv = { state: 'constructing' };
60
+ INTERNALS.set(model, priv);
61
+ }
62
+ return priv;
63
+ }
64
+
65
+ /** Load the model from storage, marking it ready whether or not the read succeeds. */
66
+ async function hydrate(model: Model<any>, priv: ModelInternals): Promise<void> {
67
+ ensureUnloadListener(PersistentStorage);
68
+ try {
69
+ await model.fetch({ fromStorage: true, promise: true });
70
+ } finally {
71
+ priv.state = 'ready';
72
+ }
73
+ }
74
+
75
+ /** Evaluate the model's declared computed properties once, seeding the cache. */
76
+ function initComputed(model: Model<any>, priv: ModelInternals): void {
77
+ const defs = result(model, 'computed') as ComputedProperties<any>;
78
+ if (!defs || isEmpty(defs)) return;
79
+ priv.computedDefs = defs;
80
+ priv.computedCache = {};
81
+ for (const [key, def] of Object.entries(defs)) {
82
+ priv.computedCache[key] = def.fn(model);
83
+ }
84
+ }
85
+
86
+ /** Recompute one computed property, firing `change:key` when its value changed. */
87
+ function updateComputed(
88
+ model: Model<any>,
89
+ priv: ModelInternals,
90
+ key: string,
91
+ def: ComputedProperty<any>,
92
+ silent: boolean,
93
+ options: ModelOptions,
94
+ ): void {
95
+ const newVal = def.fn(model);
96
+ if (!isEqual(priv.computedCache[key], newVal)) {
97
+ priv.computedCache[key] = newVal;
98
+ if (!silent) {
99
+ (model.changed as any)[key] = newVal;
100
+ model.trigger('change:' + key, model, newVal, options);
101
+ }
102
+ }
103
+ }
104
+
39
105
  /**
40
106
  * @public
41
107
  * **Models** are the basic data object in the framework --
@@ -44,9 +110,6 @@ import {
44
110
  * performing computations and transformations on that data.
45
111
  */
46
112
  export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmitterObject {
47
- #computedCache?: Record<string, any>;
48
- #computedDefs?: Record<string, ComputedProperty<this>>;
49
- #state: 'constructing' | 'hydrating' | 'ready' = 'constructing';
50
113
  _storage?: PersistentStorage;
51
114
  _changing = false;
52
115
  _pending: boolean | ModelOptions = false;
@@ -68,6 +131,7 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
68
131
  */
69
132
  constructor(attributes?: Partial<T>, options?: ModelOptions) {
70
133
  super();
134
+ const priv = internals(this);
71
135
  let attrs: Partial<T> = attributes || ({} as Partial<T>);
72
136
  options = options || {};
73
137
 
@@ -83,12 +147,13 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
83
147
 
84
148
  this.set(attrs, options);
85
149
 
86
- this.#initComputed();
150
+ initComputed(this, priv);
87
151
 
88
152
  this.attrs = new Proxy(this.attributes as T, {
89
153
  get: (_target, key) => {
90
- if (typeof key !== 'symbol' && this.#computedCache && (key as string) in this.#computedCache) {
91
- return this.#computedCache[key as string];
154
+ const cache = priv.computedCache;
155
+ if (typeof key !== 'symbol' && cache && (key as string) in cache) {
156
+ return cache[key as string];
92
157
  }
93
158
  return (this.attributes as any)[key];
94
159
  },
@@ -109,14 +174,14 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
109
174
  // otherwise it's already ready and resolves immediately.
110
175
  if (this.autoSync) {
111
176
  if (getStorage(this) && !this.isNew()) {
112
- this.#state = 'hydrating';
113
- this.hydrated = this.#hydrate();
177
+ priv.state = 'hydrating';
178
+ this.hydrated = hydrate(this, priv);
114
179
  } else {
115
- this.#state = 'ready';
180
+ priv.state = 'ready';
116
181
  this.hydrated = Promise.resolve();
117
182
  }
118
183
  } else {
119
- this.#state = 'ready';
184
+ priv.state = 'ready';
120
185
  }
121
186
  }
122
187
 
@@ -161,19 +226,6 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
161
226
  return 100;
162
227
  }
163
228
 
164
- async #hydrate(): Promise<void> {
165
- ensureUnloadListener(PersistentStorage);
166
- try {
167
- await this.fetch({ fromStorage: true, promise: true });
168
- } finally {
169
- this.#state = 'ready';
170
- }
171
- }
172
-
173
- #doAutoSave(): unknown {
174
- return this.save(null, { noAutoSave: true });
175
- }
176
-
177
229
  /**
178
230
  * The default name for the JSON `id` attribute is `"id"`. MongoDB and
179
231
  * CouchDB users may want to set this to `"_id"` (by overriding this getter
@@ -226,27 +278,6 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
226
278
  return {};
227
279
  }
228
280
 
229
- #updateComputed(key: string, def: ComputedProperty<this>, silent: boolean, options: ModelOptions): void {
230
- const newVal = def.fn(this);
231
- if (!isEqual(this.#computedCache[key], newVal)) {
232
- this.#computedCache[key] = newVal;
233
- if (!silent) {
234
- (this.changed as any)[key] = newVal;
235
- this.trigger('change:' + key, this, newVal, options);
236
- }
237
- }
238
- }
239
-
240
- #initComputed(): void {
241
- const defs = result(this, 'computed') as ComputedProperties<this>;
242
- if (!defs || isEmpty(defs)) return;
243
- this.#computedDefs = defs;
244
- this.#computedCache = {};
245
- for (const [key, def] of Object.entries(defs)) {
246
- this.#computedCache[key] = def.fn(this);
247
- }
248
- }
249
-
250
281
  /**
251
282
  * Return a copy of the model's `attributes` object.
252
283
  */
@@ -265,8 +296,9 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
265
296
  * Get the value of an attribute, or a computed property.
266
297
  */
267
298
  get<K extends keyof T>(attr: K): T[K] {
268
- if (this.#computedCache && (attr as string) in this.#computedCache) {
269
- return this.#computedCache[attr as string];
299
+ const cache = internals(this).computedCache;
300
+ if (cache && (attr as string) in cache) {
301
+ return cache[attr as string];
270
302
  }
271
303
  return this.attributes[attr];
272
304
  }
@@ -352,10 +384,11 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
352
384
  }
353
385
 
354
386
  options = options || {};
387
+ const priv = internals(this);
355
388
 
356
389
  // Guard must come after arg normalization: when called as set(obj, opts),
357
390
  // fromStorage lives in `val` (second arg), not `options` (third arg).
358
- if (this.autoSync && this.#state === 'hydrating' && !options.fromStorage) {
391
+ if (this.autoSync && priv.state === 'hydrating' && !options.fromStorage) {
359
392
  throw new Error(
360
393
  `Skeletor: set() called on an autoSync model before hydration resolved. Await model.hydrated first.`,
361
394
  );
@@ -404,10 +437,10 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
404
437
  }
405
438
 
406
439
  // Recalculate computed properties whose deps changed.
407
- if (changes.length && this.#computedDefs) {
408
- for (const [key, def] of Object.entries(this.#computedDefs)) {
440
+ if (changes.length && priv.computedDefs) {
441
+ for (const [key, def] of Object.entries(priv.computedDefs)) {
409
442
  if (def.deps.some((dep) => changes.includes(dep))) {
410
- this.#updateComputed(key, def, silent, options);
443
+ updateComputed(this, priv, key, def, silent, options);
411
444
  }
412
445
  }
413
446
  }
@@ -430,14 +463,14 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
430
463
  // were actual changes that originated from application code (not a
431
464
  // storage read or an explicit noAutoSave call).
432
465
  if (
433
- this.#state === 'ready' &&
466
+ priv.state === 'ready' &&
434
467
  this.autoSync &&
435
468
  getStorage(this) &&
436
469
  changes.length &&
437
470
  !options.fromStorage &&
438
471
  !options.noAutoSave
439
472
  ) {
440
- scheduleAutoSave(this, () => this.#doAutoSave(), this.autoSyncDelay);
473
+ scheduleAutoSave(this, () => this.save(null, { noAutoSave: true }), this.autoSyncDelay);
441
474
  }
442
475
 
443
476
  return this;
package/src/types.ts CHANGED
@@ -62,10 +62,7 @@ export type EventListenerMap = Record<string, ListeningType>;
62
62
  /**
63
63
  * @public
64
64
  */
65
- export interface EventHandlersMap {
66
- all?: EventHandler[];
67
- [name: string]: EventHandler[];
68
- }
65
+ export type EventHandlersMap = Record<string, EventHandler[]>;
69
66
 
70
67
  /**
71
68
  * @public
@@ -168,7 +165,9 @@ export type IterateeFunction = (
168
165
  */
169
166
  export type ComputedProperty<M> = {
170
167
  deps: string[];
171
- fn: (model: M) => any;
168
+ // Method syntax, so that TypeScript checks `fn` bivariantly and
169
+ // `ComputedProperties<Subclass>` stays assignable to `ComputedProperties<Model>`.
170
+ fn(model: M): any;
172
171
  };
173
172
 
174
173
  /**
@@ -198,7 +197,7 @@ export type FetchOrCreateOptions = Options & {
198
197
  * @public
199
198
  */
200
199
  export type CollectionOptions<T extends Model = Model> = Options & {
201
- model?: new (attributes?: Partial<ModelAttributes>, options?: ModelOptions) => T;
200
+ model?: new (attributes?: any, options?: any) => T;
202
201
  comparator?: Comparator<T>;
203
202
  previousModels?: Model[];
204
203
  };