@converse/skeletor 3.0.1 → 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/src/model.ts CHANGED
@@ -1,4 +1,12 @@
1
- import { getResolveablePromise, getSyncMethod, urlError, wrapError } from './helpers';
1
+ import {
2
+ getResolveablePromise,
3
+ getStorage,
4
+ getSyncMethod,
5
+ urlError,
6
+ warnBrowserStorageDeprecation,
7
+ wrapError,
8
+ } from './helpers';
9
+ import { scheduleAutoSave, cancelAutoSave, awaitInFlightSaves, ensureUnloadListener } from './autosync';
2
10
  import clone from 'lodash-es/clone';
3
11
  import defaults from 'lodash-es/defaults';
4
12
  import defer from 'lodash-es/defer';
@@ -12,11 +20,20 @@ import pick from 'lodash-es/pick';
12
20
  import result from 'lodash-es/result';
13
21
  import uniqueId from 'lodash-es/uniqueId';
14
22
  import { EventEmitterObject } from './eventemitter';
23
+ import PersistentStorage from './storage';
15
24
 
16
25
  // Import types
17
26
  import type { Collection } from './collection';
18
- import type Storage from './storage';
19
- import { ModelAttributes, ObjectWithId, SyncOperation, ModelOptions, Options } from './types';
27
+ import {
28
+ ComputedProperties,
29
+ ComputedProperty,
30
+ EventCallback,
31
+ ModelAttributes,
32
+ ObjectWithId,
33
+ SyncOperation,
34
+ ModelOptions,
35
+ Options,
36
+ } from './types';
20
37
 
21
38
  /**
22
39
  * @public
@@ -26,17 +43,22 @@ import { ModelAttributes, ObjectWithId, SyncOperation, ModelOptions, Options } f
26
43
  * performing computations and transformations on that data.
27
44
  */
28
45
  export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmitterObject {
29
- _browserStorage?: Storage;
46
+ #computedCache?: Record<string, any>;
47
+ #computedDefs?: Record<string, ComputedProperty<this>>;
48
+ #state: 'constructing' | 'hydrating' | 'ready' = 'constructing';
49
+ _storage?: PersistentStorage;
30
50
  _changing = false;
31
51
  _pending: boolean | ModelOptions = false;
32
52
  _previousAttributes?: T;
33
53
  _url: string = '';
34
54
  _urlRoot: string;
55
+ attrs: T;
35
56
  attributes: T;
36
57
  changed: Partial<T> = {};
37
58
  cid: string;
38
59
  collection?: Collection;
39
60
  id: string | number;
61
+ initialized?: Promise<void>;
40
62
  validationError: string | number | null = null;
41
63
 
42
64
  /**
@@ -60,18 +82,99 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
60
82
 
61
83
  this.set(attrs, options);
62
84
 
85
+ this.#initComputed();
86
+
87
+ this.attrs = new Proxy(this.attributes as T, {
88
+ get: (_target, key) => {
89
+ if (typeof key !== 'symbol' && this.#computedCache && (key as string) in this.#computedCache) {
90
+ return this.#computedCache[key as string];
91
+ }
92
+ return (this.attributes as any)[key];
93
+ },
94
+ set: (_target, key, value) => {
95
+ this.set(key as string, value);
96
+ return true;
97
+ },
98
+ });
99
+
63
100
  this.initialize.apply(this, arguments as any);
64
101
 
65
102
  // Reset changed after initial set
66
103
  this.changed = {};
104
+
105
+ // When autoSync is on, `initialized` is always a Promise so callers can
106
+ // uniformly `await model.initialized`. It only hydrates from storage when
107
+ // there's something to load (storage configured and the model isn't new);
108
+ // otherwise it's already ready and resolves immediately.
109
+ if (this.autoSync) {
110
+ if (getStorage(this) && !this.isNew()) {
111
+ this.#state = 'hydrating';
112
+ this.initialized = this.#hydrate();
113
+ } else {
114
+ this.#state = 'ready';
115
+ this.initialized = Promise.resolve();
116
+ }
117
+ } else {
118
+ this.#state = 'ready';
119
+ }
67
120
  }
68
121
 
69
- set browserStorage(storage: Storage) {
70
- this._browserStorage = storage;
122
+ /**
123
+ * The canonical storage accessor. Set to a `PersistentStorage` instance
124
+ * to enable local persistence for this model.
125
+ */
126
+ get storage(): PersistentStorage | undefined {
127
+ return this._storage;
71
128
  }
72
129
 
73
- get browserStorage(): Storage | undefined {
74
- return this._browserStorage;
130
+ set storage(s: PersistentStorage) {
131
+ this._storage = s;
132
+ PersistentStorage.register(s);
133
+ }
134
+
135
+ /**
136
+ * @deprecated Use `storage` instead.
137
+ */
138
+ get browserStorage(): PersistentStorage | undefined {
139
+ return this._storage;
140
+ }
141
+
142
+ set browserStorage(s: PersistentStorage) {
143
+ warnBrowserStorageDeprecation(this);
144
+ this.storage = s;
145
+ }
146
+
147
+ /**
148
+ * Override to enable automatic persistence. When true, any `set()` call
149
+ * that changes attributes will schedule a debounced save to `storage`, and
150
+ * the model will auto-hydrate from storage on construction.
151
+ */
152
+ get autoSync(): boolean {
153
+ return false;
154
+ }
155
+
156
+ /**
157
+ * Debounce delay (ms) for auto-save writes. Override to tune.
158
+ */
159
+ get autoSyncDelay(): number {
160
+ return 100;
161
+ }
162
+
163
+ async #hydrate(): Promise<void> {
164
+ ensureUnloadListener(PersistentStorage);
165
+ try {
166
+ await this.fetch({ fromStorage: true, promise: true });
167
+ } catch (e) {
168
+ // 'Record Not Found' is a normal first-run state (nothing stored yet);
169
+ // keep the initial attributes and resolve. Re-throw anything else.
170
+ if (e !== 'Record Not Found') throw e;
171
+ } finally {
172
+ this.#state = 'ready';
173
+ }
174
+ }
175
+
176
+ #doAutoSave(): unknown {
177
+ return this.save(null, { noAutoSave: true });
75
178
  }
76
179
 
77
180
  /**
@@ -114,6 +217,39 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
114
217
  return {} as Partial<T>;
115
218
  }
116
219
 
220
+ /**
221
+ * Declare computed properties. Override in subclasses to define properties
222
+ * that derive their value from other attributes. Each entry specifies the
223
+ * attribute keys it depends on (`deps`) and a function (`fn`) that receives
224
+ * the model and returns the computed value. The value is cached and
225
+ * recalculated automatically when any dep changes, firing a `change:key`
226
+ * event.
227
+ */
228
+ get computed(): ComputedProperties<this> {
229
+ return {};
230
+ }
231
+
232
+ #updateComputed(key: string, def: ComputedProperty<this>, silent: boolean, options: ModelOptions): void {
233
+ const newVal = def.fn(this);
234
+ if (!isEqual(this.#computedCache[key], newVal)) {
235
+ this.#computedCache[key] = newVal;
236
+ if (!silent) {
237
+ (this.changed as any)[key] = newVal;
238
+ this.trigger('change:' + key, this, newVal, options);
239
+ }
240
+ }
241
+ }
242
+
243
+ #initComputed(): void {
244
+ const defs = result(this, 'computed') as ComputedProperties<this>;
245
+ if (!defs || isEmpty(defs)) return;
246
+ this.#computedDefs = defs;
247
+ this.#computedCache = {};
248
+ for (const [key, def] of Object.entries(defs)) {
249
+ this.#computedCache[key] = def.fn(this);
250
+ }
251
+ }
252
+
117
253
  /**
118
254
  * Return a copy of the model's `attributes` object.
119
255
  */
@@ -129,9 +265,12 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
129
265
  }
130
266
 
131
267
  /**
132
- * Get the value of an attribute.
268
+ * Get the value of an attribute, or a computed property.
133
269
  */
134
270
  get<K extends keyof T>(attr: K): T[K] {
271
+ if (this.#computedCache && (attr as string) in this.#computedCache) {
272
+ return this.#computedCache[attr as string];
273
+ }
135
274
  return this.attributes[attr];
136
275
  }
137
276
 
@@ -208,6 +347,14 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
208
347
 
209
348
  options = options || {};
210
349
 
350
+ // Guard must come after arg normalization: when called as set(obj, opts),
351
+ // fromStorage lives in `val` (second arg), not `options` (third arg).
352
+ if (this.autoSync && this.#state === 'hydrating' && !options.fromStorage) {
353
+ throw new Error(
354
+ `Skeletor: set() called on an autoSync model before initialized resolved. Await model.initialized first.`,
355
+ );
356
+ }
357
+
211
358
  // Run validation.
212
359
  if (!this._validate(attrs, options)) return null;
213
360
 
@@ -250,6 +397,15 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
250
397
  }
251
398
  }
252
399
 
400
+ // Recalculate computed properties whose deps changed.
401
+ if (changes.length && this.#computedDefs) {
402
+ for (const [key, def] of Object.entries(this.#computedDefs)) {
403
+ if (def.deps.some((dep) => changes.includes(dep))) {
404
+ this.#updateComputed(key, def, silent, options);
405
+ }
406
+ }
407
+ }
408
+
253
409
  if (changing) return this;
254
410
 
255
411
  if (!silent) {
@@ -263,6 +419,21 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
263
419
  }
264
420
  this._pending = false;
265
421
  this._changing = false;
422
+
423
+ // Auto-save: schedule a debounced write when autoSync is on and there
424
+ // were actual changes that originated from application code (not a
425
+ // storage read or an explicit noAutoSave call).
426
+ if (
427
+ this.#state === 'ready' &&
428
+ this.autoSync &&
429
+ getStorage(this) &&
430
+ changes.length &&
431
+ !options.fromStorage &&
432
+ !options.noAutoSave
433
+ ) {
434
+ scheduleAutoSave(this, () => this.#doAutoSave(), this.autoSyncDelay);
435
+ }
436
+
266
437
  return this;
267
438
  }
268
439
 
@@ -342,16 +513,50 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
342
513
  options = Object.assign({ parse: true }, options);
343
514
 
344
515
  const success = options.success;
516
+ const promise = options.promise ? getResolveablePromise() : undefined;
517
+
518
+ // Tracks whether sync invoked one of the callbacks. A sync may instead
519
+ // settle its returned promise; the `.then`/`.catch` below bridges that to
520
+ // the callbacks so the fetch promise never hangs. The flag is set
521
+ // synchronously inside each callback (before sync's promise resolves), so
522
+ // it's reliable by the time those handlers run.
523
+ let settled = false;
345
524
 
346
525
  options.success = (resp: any) => {
526
+ settled = true;
347
527
  const serverAttrs = options.parse ? this.parse(resp, options) : resp;
348
- if (!this.set(serverAttrs, options)) return false;
528
+ // fromStorage prevents the set() from triggering auto-save on reads
529
+ if (!this.set(serverAttrs, Object.assign({}, options, { fromStorage: true }))) return false;
349
530
  if (success) success.call(options.context, this, resp, options);
350
531
  this.trigger('sync', this, resp, options);
532
+ promise && promise.resolve(resp);
351
533
  };
352
534
 
353
- wrapError(this, options);
354
- return this.sync('read', this, options);
535
+ wrapError(this, options, promise);
536
+ if (promise) {
537
+ const onError = options.error;
538
+ options.error = (resp: any) => {
539
+ settled = true;
540
+ onError(resp);
541
+ };
542
+ }
543
+
544
+ const result = this.sync('read', this, options);
545
+ if (promise) {
546
+ Promise.resolve(result).then(
547
+ // A sync that resolves its promise without invoking options.success:
548
+ // treat the resolved value as the response, so the model is still
549
+ // merged and the promise settles instead of hanging.
550
+ (value) => {
551
+ if (!settled) options.success(value);
552
+ },
553
+ // A sync that rejects instead of invoking options.error; settling
554
+ // twice is a harmless no-op.
555
+ (e) => promise.reject(e),
556
+ );
557
+ return promise;
558
+ }
559
+ return result;
355
560
  }
356
561
 
357
562
  /**
@@ -431,6 +636,7 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
431
636
  * If `wait: true` is passed, waits for the server to respond before removal.
432
637
  */
433
638
  destroy(options?: ModelOptions): any {
639
+ cancelAutoSave(this);
434
640
  options = options ? clone(options) : {};
435
641
  const success = options.success;
436
642
  const wait = options.wait;
@@ -446,12 +652,24 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
446
652
  if (!this.isNew()) this.trigger('sync', this, resp, options);
447
653
  };
448
654
 
655
+ // A debounced auto-save may already have fired and be in flight.
656
+ // cancelAutoSave() above only stops a *pending* timer, so sequence the
657
+ // delete after any in-flight save; otherwise the late write could land
658
+ // after the delete and resurrect the record. Capture the collection now,
659
+ // the optimistic in-memory removal below nulls `model.collection` before
660
+ // the deferred sync runs, but the storage layer still needs it to drop
661
+ // this id from the collection's persisted index.
662
+ const inFlightSave = awaitInFlightSaves(this);
663
+ if (inFlightSave && this.collection) options.collection = this.collection;
664
+
449
665
  let xhr: any = false;
450
666
  if (this.isNew()) {
451
667
  defer(options.success);
452
668
  } else {
453
669
  wrapError(this, options);
454
- xhr = this.sync('delete', this, options);
670
+ xhr = inFlightSave
671
+ ? inFlightSave.then(() => this.sync('delete', this, options))
672
+ : this.sync('delete', this, options);
455
673
  }
456
674
  if (!wait) destroy();
457
675
  return xhr;
@@ -505,6 +723,29 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
505
723
  return this._validate({}, Object.assign({}, options, { validate: true }));
506
724
  }
507
725
 
726
+ /**
727
+ * Subscribe to model changes. Returns an unsubscribe function.
728
+ *
729
+ * `model.subscribe(callback)` — fires on any attribute change, receives `(model, changed)`.
730
+ * `model.subscribe(event, callback)` — subscribe to a specific event (base EventEmitter form).
731
+ *
732
+ * Compatible with React's `useSyncExternalStore` and other store-style APIs.
733
+ */
734
+ subscribe(event: string, callback: EventCallback, context?: unknown): () => void;
735
+ subscribe(callback: (model: this, changed: Partial<T>) => void): () => void;
736
+ subscribe(
737
+ eventOrCallback: string | ((model: this, changed: Partial<T>) => void),
738
+ callback?: EventCallback,
739
+ context?: unknown,
740
+ ): () => void {
741
+ if (typeof eventOrCallback === 'function') {
742
+ const cb = (model: this) => (eventOrCallback as (model: this, changed: Partial<T>) => void)(model, this.changed);
743
+ this.on('change', cb);
744
+ return () => this.off('change', cb);
745
+ }
746
+ return super.subscribe(eventOrCallback, callback, context);
747
+ }
748
+
508
749
  /**
509
750
  * Run validation against the next complete set of model attributes,
510
751
  * returning `true` if all is well. Otherwise, fire an `"invalid"` event.
package/src/storage.ts CHANGED
@@ -13,54 +13,76 @@ import { guid } from './helpers';
13
13
  import type { Model } from './model';
14
14
  import type { Collection } from './collection';
15
15
  import type { SyncOptions, SyncOperation } from './types';
16
+ import { StorageDriver, StoreType } from './drivers/types';
16
17
 
17
18
  const IN_MEMORY = memoryDriver._driver;
18
19
  localForage.defineDriver(memoryDriver);
19
20
  extendPrototypeWithSetItems(localForage);
20
21
  extendPrototypeWithGetItems(localForage);
21
22
 
22
- /**
23
- * @public
24
- */
25
- export interface LocalForageWithExtensions {
26
- setItem(key: string, value: any): Promise<any>;
27
- getItem(key: string): Promise<any>;
28
- removeItem(key: string): Promise<void>;
29
- clear(): Promise<void>;
30
- length(): Promise<number>;
31
- key(keyIndex: number): Promise<string>;
32
- keys(): Promise<string[]>;
33
- setItems?(items: Record<string, any>): Promise<void>;
34
- getItems?(keys: string[]): Promise<Record<string, any>>;
35
- debouncedSetItems?: {
36
- (items: Record<string, any>): Promise<void>;
37
- flush?: () => void;
38
- };
39
- }
23
+ // Every storage instance is registered with a WeakRef so that flushAll() can
24
+ // reach pending writes without keeping instances alive. There's no leak and no
25
+ // lifecycle hook to maintain: an instance is collected once its owning model or
26
+ // collection is gone, at which point nothing is writing through it anyway.
27
+ // Models with a pending debounced save stay reachable via the autosync `pending`
28
+ // map until the timer fires, so flushAll() always reaches in-flight writes.
29
+ const weakInstances = new Set<WeakRef<PersistentStorage>>();
30
+
31
+ // Tracks which instances already have a WeakRef in `weakInstances`, so repeated
32
+ // `storage =` assignments (or a store shared across models) don't register the
33
+ // same instance twice and make flushAll() flush it more than once.
34
+ const registered = new WeakSet<PersistentStorage>();
40
35
 
41
36
  /**
42
37
  * @public
43
38
  */
44
- class BrowserStorage {
39
+ class PersistentStorage {
45
40
  storeInitialized: Promise<void>;
46
- store: LocalForageWithExtensions;
41
+ // Set once `storeInitialized` resolves. Lets `sync()` skip awaiting it on the
42
+ // hot path so a save buffers/issues its write *synchronously*, which is what
43
+ // makes the synchronous unload flush (`flushPending` -> `flushAll`) able to
44
+ // push a pending write out before the page is torn down.
45
+ storeReady = false;
46
+ store: StorageDriver;
47
47
  name: string;
48
48
 
49
49
  static sessionStorageInitialized: Promise<void>;
50
50
  static localForage: typeof localForage;
51
51
 
52
- constructor(
53
- id: string,
54
- type: 'local' | 'session' | 'indexed' | 'in_memory' | LocalForageWithExtensions,
55
- batchedWrites = false
56
- ) {
57
- if (type === 'local' && !window.localStorage) {
52
+ /** Register a storage instance with a weak reference. @public */
53
+ static register(instance: PersistentStorage): void {
54
+ if (registered.has(instance)) return;
55
+ registered.add(instance);
56
+ weakInstances.add(new WeakRef(instance));
57
+ }
58
+
59
+ /**
60
+ * Flush the debounced write buffer of every registered PersistentStorage
61
+ * instance. Called automatically on pagehide/visibilitychange when autoSync
62
+ * is in use; can also be called manually.
63
+ * @public
64
+ */
65
+ static flushAll(): void {
66
+ for (const ref of weakInstances) {
67
+ const instance = ref.deref();
68
+ if (!instance) {
69
+ weakInstances.delete(ref);
70
+ } else {
71
+ instance.flush();
72
+ }
73
+ }
74
+ }
75
+
76
+ constructor(id: string, type: StoreType | StorageDriver, batchedWrites = false) {
77
+ this.name = id;
78
+
79
+ if (type === 'local' && typeof window !== 'undefined' && !window.localStorage) {
58
80
  throw new Error('Skeletor.storage: Environment does not support localStorage.');
59
- } else if (type === 'session' && !window.sessionStorage) {
81
+ } else if (type === 'session' && typeof window !== 'undefined' && !window.sessionStorage) {
60
82
  throw new Error('Skeletor.storage: Environment does not support sessionStorage.');
61
83
  }
62
84
  if (isString(type)) {
63
- this.storeInitialized = this.initStore(type as 'local' | 'session' | 'indexed' | 'in_memory', batchedWrites);
85
+ this.storeInitialized = this.initStore(type as StoreType, batchedWrites);
64
86
  } else {
65
87
  this.store = type;
66
88
  if (batchedWrites) {
@@ -70,24 +92,36 @@ class BrowserStorage {
70
92
  }
71
93
  this.storeInitialized = Promise.resolve();
72
94
  }
73
- this.name = id;
95
+ // Flip the synchronous-readiness flag once init settles. Callers that await
96
+ // `storeInitialized` directly still see any rejection; this branch only
97
+ // exists to set the flag and must not raise an unhandled rejection.
98
+ this.storeInitialized.then(() => (this.storeReady = true)).catch(() => {});
74
99
  }
75
100
 
76
101
  /**
77
- * @param type - The storage type: 'local', 'session', 'indexed', or 'in_memory'
102
+ * @param type - The storage type: 'local', 'session', 'indexed', 'node', or 'in_memory'
78
103
  * @param batchedWrites - Whether to enable batched writes
79
104
  */
80
- async initStore(type: 'local' | 'session' | 'indexed' | 'in_memory', batchedWrites: boolean): Promise<void> {
105
+ async initStore(type: StoreType, batchedWrites: boolean): Promise<void> {
81
106
  if (type === 'session') {
82
107
  await localForage.setDriver(sessionStorageWrapper._driver);
83
108
  } else if (type === 'local') {
84
109
  await localForage.config({ 'driver': localForage.LOCALSTORAGE });
85
110
  } else if (type === 'in_memory') {
86
111
  await localForage.config({ 'driver': IN_MEMORY });
112
+ } else if (type === 'node') {
113
+ const { NodeSQLiteStorage } = await import('./drivers/nodeSQLiteStorage');
114
+ this.store = new NodeSQLiteStorage(this.name);
115
+ if (batchedWrites) {
116
+ this.store.debouncedSetItems = mergebounce((items: Record<string, any>) => this.store.setItems!(items), 50, {
117
+ 'promise': true,
118
+ });
119
+ }
120
+ return;
87
121
  } else if (type !== 'indexed') {
88
122
  throw new Error('Skeletor.storage: No storage type was specified');
89
123
  }
90
- this.store = localForage as LocalForageWithExtensions;
124
+ this.store = localForage as StorageDriver;
91
125
  if (batchedWrites) {
92
126
  this.store.debouncedSetItems = mergebounce((items: Record<string, any>) => this.store.setItems!(items), 50, {
93
127
  'promise': true,
@@ -102,6 +136,7 @@ class BrowserStorage {
102
136
  }
103
137
 
104
138
  async clear(): Promise<void> {
139
+ await this.storeInitialized;
105
140
  await this.store.removeItem(this.name).catch((e) => console.error(e));
106
141
  const re = new RegExp(`^${this.name}-`);
107
142
  const keys = await this.store.keys();
@@ -119,12 +154,17 @@ class BrowserStorage {
119
154
  // We get the collection (and if necessary the model attribute.
120
155
  // Waiting for storeInitialized will cause another iteration of
121
156
  // the event loop, after which the collection reference will
122
- // be removed from the model.
123
- const collection = model.collection;
157
+ // be removed from the model. `options.collection` lets a deferred
158
+ // delete (sequenced behind an in-flight auto-save by Model.destroy)
159
+ // supply the collection captured before its optimistic removal.
160
+ const collection = options.collection ?? model.collection;
124
161
  if (['patch', 'update'].includes(method)) {
125
162
  new_attributes = cloneDeep(model.attributes);
126
163
  }
127
- await that.storeInitialized;
164
+
165
+ // Skip `await` if not necessary to allow for sync flushing
166
+ if (!that.storeReady) await that.storeInitialized;
167
+
128
168
  try {
129
169
  const original_attributes = model.attributes;
130
170
  switch (method) {
@@ -288,6 +328,6 @@ class BrowserStorage {
288
328
  }
289
329
  }
290
330
 
291
- BrowserStorage.sessionStorageInitialized = localForage.defineDriver(sessionStorageWrapper);
292
- BrowserStorage.localForage = localForage;
293
- export default BrowserStorage;
331
+ PersistentStorage.sessionStorageInitialized = localForage.defineDriver(sessionStorageWrapper);
332
+ PersistentStorage.localForage = localForage;
333
+ export default PersistentStorage;
package/src/types.ts CHANGED
@@ -1,5 +1,5 @@
1
- import {Collection} from './collection';
2
- import {Model} from './model';
1
+ import { Collection } from './collection';
2
+ import { Model } from './model';
3
3
 
4
4
  /**
5
5
  * @public
@@ -9,7 +9,7 @@ export type SyncOperation = 'create' | 'update' | 'patch' | 'delete' | 'read';
9
9
  /**
10
10
  * @public
11
11
  */
12
- export type ObjectListenedTo = object & {_listenId?: string};
12
+ export type ObjectListenedTo = object & { _listenId?: string };
13
13
 
14
14
  /**
15
15
  * @public
@@ -22,6 +22,11 @@ export interface SyncOptions {
22
22
  error?: (error: any) => void;
23
23
  xhr?: any;
24
24
  wait?: boolean;
25
+ // Lets `Model.destroy` pass the collection captured before its optimistic
26
+ // (synchronous) in-memory removal nulled `model.collection`, so the storage
27
+ // layer can still drop the id from the collection's persisted index even
28
+ // when the delete is deferred behind an in-flight auto-save.
29
+ collection?: Collection;
25
30
  }
26
31
 
27
32
  /**
@@ -70,7 +75,7 @@ export type ListeningMap = Record<string, ListeningType>;
70
75
  /**
71
76
  * @public
72
77
  */
73
- export type ObjectWithId = Record<string, any> & {id: string | number};
78
+ export type ObjectWithId = Record<string, any> & { id: string | number };
74
79
 
75
80
  /**
76
81
  * @public
@@ -85,7 +90,7 @@ export interface IEventEmitter {
85
90
  off(
86
91
  name?: string | EventCallbackMap | null,
87
92
  callback?: EventCallback | EventContext | null,
88
- context?: EventContext
93
+ context?: EventContext,
89
94
  ): this;
90
95
  trigger(name: string, ...args: any[]): this;
91
96
  stopListening(obj?: any, name?: string | EventCallbackMap, callback?: EventCallback): this;
@@ -135,7 +140,7 @@ export interface EventsApiOptions {
135
140
  */
136
141
  export interface OffApiOptions {
137
142
  context?: any;
138
- listeners?: {[key: string]: ListeningType};
143
+ listeners?: { [key: string]: ListeningType };
139
144
  }
140
145
 
141
146
  /**
@@ -155,21 +160,37 @@ export type IterateeFunction = (
155
160
  eventsOrMap: EventCallbackMap | EventHandlersMap,
156
161
  name: string | null,
157
162
  callback: EventCallback | EventHandler | null,
158
- options: EventsApiOptions | OffApiOptions | OfferFunction | any[]
163
+ options: EventsApiOptions | OffApiOptions | OfferFunction | any[],
159
164
  ) => EventCallbackMap | EventHandlersMap | void;
160
165
 
161
166
  /**
162
167
  * @public
163
168
  */
164
- export type ModelAttributes = Record<string | number, any> & {id?: string | number};
169
+ export type ComputedProperty<M> = {
170
+ deps: string[];
171
+ fn: (model: M) => any;
172
+ };
173
+
174
+ /**
175
+ * @public
176
+ */
177
+ export type ComputedProperties<M> = Record<string, ComputedProperty<M>>;
178
+
179
+ /**
180
+ * @public
181
+ */
182
+ export type ModelAttributes = Record<string | number, any> & { id?: string | number };
165
183
 
166
184
  /**
167
185
  * @public
168
186
  */
169
187
  export type Options = Record<string, any>;
170
188
 
189
+ /**
190
+ * @public
191
+ */
171
192
  export type FetchOrCreateOptions = Options & {
172
- promise?: boolean,
193
+ promise?: boolean;
173
194
  success?: (m: Model, resp: any, callbackOpts: Options) => void;
174
195
  };
175
196
 
@@ -191,4 +212,8 @@ export type ModelOptions = Options & {
191
212
  unset?: boolean;
192
213
  silent?: boolean;
193
214
  validate?: boolean;
215
+ /** Suppress the auto-save triggered by set() on autoSync models. */
216
+ noAutoSave?: boolean;
217
+ /** Mark a set() call as originating from a storage read; suppresses auto-save. */
218
+ fromStorage?: boolean;
194
219
  };