@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/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,87 @@ 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
 
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>();
35
+
22
36
  /**
23
- * @public
37
+ * Constructor for a Node.js-only storage driver. Registered by the
38
+ * `@converse/skeletor/node` entry point via {@link PersistentStorage.nodeStorage}.
24
39
  */
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
- }
40
+ type NodeStorageConstructor = new (name: string) => StorageDriver;
40
41
 
41
42
  /**
42
43
  * @public
43
44
  */
44
- class BrowserStorage {
45
+ class PersistentStorage {
45
46
  storeInitialized: Promise<void>;
46
- store: LocalForageWithExtensions;
47
+ // Set once `storeInitialized` resolves. Lets `sync()` skip awaiting it on the
48
+ // hot path so a save buffers/issues its write *synchronously*, which is what
49
+ // makes the synchronous unload flush (`flushPending` -> `flushAll`) able to
50
+ // push a pending write out before the page is torn down.
51
+ storeReady = false;
52
+ store: StorageDriver;
47
53
  name: string;
48
54
 
49
55
  static sessionStorageInitialized: Promise<void>;
50
56
  static localForage: typeof localForage;
51
57
 
52
- constructor(
53
- id: string,
54
- type: 'local' | 'session' | 'indexed' | 'in_memory' | LocalForageWithExtensions,
55
- batchedWrites = false
56
- ) {
57
- if (type === 'local' && !window.localStorage) {
58
+ // The Node.js SQLite storage driver. Left null in browser builds so the
59
+ // node-only driver (and its `node:sqlite` import) never enters the browser
60
+ // bundle; the `@converse/skeletor/node` entry sets it.
61
+ static nodeStorage: NodeStorageConstructor | null = null;
62
+
63
+ /** Register a storage instance with a weak reference. @public */
64
+ static register(instance: PersistentStorage): void {
65
+ if (registered.has(instance)) return;
66
+ registered.add(instance);
67
+ weakInstances.add(new WeakRef(instance));
68
+ }
69
+
70
+ /**
71
+ * Flush the debounced write buffer of every registered PersistentStorage
72
+ * instance. Called automatically on pagehide/visibilitychange when autoSync
73
+ * is in use; can also be called manually.
74
+ * @public
75
+ */
76
+ static flushAll(): void {
77
+ for (const ref of weakInstances) {
78
+ const instance = ref.deref();
79
+ if (!instance) {
80
+ weakInstances.delete(ref);
81
+ } else {
82
+ instance.flush();
83
+ }
84
+ }
85
+ }
86
+
87
+ constructor(id: string, type: StoreType | StorageDriver, batchedWrites = false) {
88
+ this.name = id;
89
+
90
+ if (type === 'local' && typeof window !== 'undefined' && !window.localStorage) {
58
91
  throw new Error('Skeletor.storage: Environment does not support localStorage.');
59
- } else if (type === 'session' && !window.sessionStorage) {
92
+ } else if (type === 'session' && typeof window !== 'undefined' && !window.sessionStorage) {
60
93
  throw new Error('Skeletor.storage: Environment does not support sessionStorage.');
61
94
  }
62
95
  if (isString(type)) {
63
- this.storeInitialized = this.initStore(type as 'local' | 'session' | 'indexed' | 'in_memory', batchedWrites);
96
+ this.storeInitialized = this.initStore(type as StoreType, batchedWrites);
64
97
  } else {
65
98
  this.store = type;
66
99
  if (batchedWrites) {
@@ -70,24 +103,42 @@ class BrowserStorage {
70
103
  }
71
104
  this.storeInitialized = Promise.resolve();
72
105
  }
73
- this.name = id;
106
+ // Flip the synchronous-readiness flag once init settles. Callers that await
107
+ // `storeInitialized` directly still see any rejection; this branch only
108
+ // exists to set the flag and must not raise an unhandled rejection.
109
+ this.storeInitialized.then(() => (this.storeReady = true)).catch(() => {});
74
110
  }
75
111
 
76
112
  /**
77
- * @param type - The storage type: 'local', 'session', 'indexed', or 'in_memory'
113
+ * @param type - The storage type: 'local', 'session', 'indexed', 'node', or 'in_memory'
78
114
  * @param batchedWrites - Whether to enable batched writes
79
115
  */
80
- async initStore(type: 'local' | 'session' | 'indexed' | 'in_memory', batchedWrites: boolean): Promise<void> {
116
+ async initStore(type: StoreType, batchedWrites: boolean): Promise<void> {
81
117
  if (type === 'session') {
82
118
  await localForage.setDriver(sessionStorageWrapper._driver);
83
119
  } else if (type === 'local') {
84
120
  await localForage.config({ 'driver': localForage.LOCALSTORAGE });
85
121
  } else if (type === 'in_memory') {
86
122
  await localForage.config({ 'driver': IN_MEMORY });
123
+ } else if (type === 'node') {
124
+ const NodeSQLiteStorage = PersistentStorage.nodeStorage;
125
+ if (!NodeSQLiteStorage) {
126
+ throw new Error(
127
+ "Skeletor.storage: the 'node' storage type is only available in Node.js builds. " +
128
+ "Import from '@converse/skeletor/node' instead of '@converse/skeletor'."
129
+ );
130
+ }
131
+ this.store = new NodeSQLiteStorage(this.name);
132
+ if (batchedWrites) {
133
+ this.store.debouncedSetItems = mergebounce((items: Record<string, any>) => this.store.setItems!(items), 50, {
134
+ 'promise': true,
135
+ });
136
+ }
137
+ return;
87
138
  } else if (type !== 'indexed') {
88
139
  throw new Error('Skeletor.storage: No storage type was specified');
89
140
  }
90
- this.store = localForage as LocalForageWithExtensions;
141
+ this.store = localForage as StorageDriver;
91
142
  if (batchedWrites) {
92
143
  this.store.debouncedSetItems = mergebounce((items: Record<string, any>) => this.store.setItems!(items), 50, {
93
144
  'promise': true,
@@ -102,6 +153,7 @@ class BrowserStorage {
102
153
  }
103
154
 
104
155
  async clear(): Promise<void> {
156
+ await this.storeInitialized;
105
157
  await this.store.removeItem(this.name).catch((e) => console.error(e));
106
158
  const re = new RegExp(`^${this.name}-`);
107
159
  const keys = await this.store.keys();
@@ -119,12 +171,17 @@ class BrowserStorage {
119
171
  // We get the collection (and if necessary the model attribute.
120
172
  // Waiting for storeInitialized will cause another iteration of
121
173
  // the event loop, after which the collection reference will
122
- // be removed from the model.
123
- const collection = model.collection;
174
+ // be removed from the model. `options.collection` lets a deferred
175
+ // delete (sequenced behind an in-flight auto-save by Model.destroy)
176
+ // supply the collection captured before its optimistic removal.
177
+ const collection = options.collection ?? model.collection;
124
178
  if (['patch', 'update'].includes(method)) {
125
179
  new_attributes = cloneDeep(model.attributes);
126
180
  }
127
- await that.storeInitialized;
181
+
182
+ // Skip `await` if not necessary to allow for sync flushing
183
+ if (!that.storeReady) await that.storeInitialized;
184
+
128
185
  try {
129
186
  const original_attributes = model.attributes;
130
187
  switch (method) {
@@ -288,6 +345,6 @@ class BrowserStorage {
288
345
  }
289
346
  }
290
347
 
291
- BrowserStorage.sessionStorageInitialized = localForage.defineDriver(sessionStorageWrapper);
292
- BrowserStorage.localForage = localForage;
293
- export default BrowserStorage;
348
+ PersistentStorage.sessionStorageInitialized = localForage.defineDriver(sessionStorageWrapper);
349
+ PersistentStorage.localForage = localForage;
350
+ 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
  };