@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.
@@ -0,0 +1,162 @@
1
+ import { DatabaseSync, type StatementSync } from 'node:sqlite';
2
+ import fsMod from 'node:fs';
3
+ import pathMod from 'node:path';
4
+ import { normalizeKey, serializeSync, deserialize, sanitizePathSegment } from './helpers';
5
+ import { StorageDriver } from './types';
6
+
7
+ const DEFAULT_BASE_DIR = '.skeletor-storage';
8
+
9
+ /**
10
+ * SQLite-backed implementation of StorageDriver.
11
+ * Uses a single table with key/value columns, storing values as JSON strings.
12
+ */
13
+ export class NodeSQLiteStorage implements StorageDriver {
14
+ private db: DatabaseSync;
15
+ private stmt: {
16
+ insert: StatementSync;
17
+ select: StatementSync;
18
+ delete: StatementSync;
19
+ clear: StatementSync;
20
+ count: StatementSync;
21
+ keyByIndex: StatementSync;
22
+ keys: StatementSync;
23
+ getItems: StatementSync;
24
+ };
25
+
26
+ ready(): Promise<void> {
27
+ return Promise.resolve();
28
+ }
29
+
30
+ debouncedSetItems?: {
31
+ (items: Record<string, any>): Promise<void>;
32
+ flush?: () => void;
33
+ };
34
+
35
+ constructor(name: string, storageDir?: string, inMemory?: boolean) {
36
+ if (inMemory) {
37
+ this.db = new DatabaseSync(':memory:');
38
+ } else {
39
+ const safeName = sanitizePathSegment(name);
40
+ const baseDir = storageDir || DEFAULT_BASE_DIR;
41
+ const dbFilePath = pathMod.join(baseDir, `${safeName}.db`);
42
+
43
+ // Ensure parent directory exists
44
+ const dir = pathMod.dirname(dbFilePath);
45
+ if (!fsMod.existsSync(dir)) {
46
+ fsMod.mkdirSync(dir, { recursive: true });
47
+ }
48
+
49
+ this.db = new DatabaseSync(dbFilePath);
50
+ }
51
+ // Use DELETE journal mode (default, no separate WAL files)
52
+ this.db.exec('PRAGMA journal_mode=DELETE');
53
+ // Enable busy timeout
54
+ this.db.exec('PRAGMA busy_timeout=5000');
55
+
56
+ // Create the kv table if it doesn't exist
57
+ this.db.exec(`
58
+ CREATE TABLE IF NOT EXISTS kv (
59
+ key TEXT PRIMARY KEY,
60
+ value TEXT NOT NULL
61
+ ) STRICT
62
+ `);
63
+
64
+ // Prepare all statements once
65
+ this.stmt = {
66
+ insert: this.db.prepare('INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)'),
67
+ select: this.db.prepare('SELECT value FROM kv WHERE key = ?'),
68
+ delete: this.db.prepare('DELETE FROM kv WHERE key = ?'),
69
+ clear: this.db.prepare('DELETE FROM kv'),
70
+ count: this.db.prepare('SELECT COUNT(*) as count FROM kv'),
71
+ keyByIndex: this.db.prepare('SELECT key FROM kv ORDER BY key LIMIT 1 OFFSET ?'),
72
+ keys: this.db.prepare('SELECT key FROM kv ORDER BY key'),
73
+ getItems: this.db.prepare('SELECT key, value FROM kv WHERE key IN (SELECT value FROM json_each(?))'),
74
+ };
75
+ }
76
+
77
+ setItem(key: string, value: any): Promise<any> {
78
+ key = normalizeKey(key);
79
+ const coerced = value ?? null;
80
+ this.stmt.insert.run(key, serializeSync(coerced));
81
+ return Promise.resolve(value);
82
+ }
83
+
84
+ getItem<T>(key: string): Promise<T | null> {
85
+ key = normalizeKey(key);
86
+ const row = this.stmt.select.get(key) as { value: string } | undefined;
87
+ if (!row) {
88
+ return Promise.resolve(null);
89
+ }
90
+ return Promise.resolve(deserialize<T>(row.value));
91
+ }
92
+
93
+ removeItem(key: string): Promise<void> {
94
+ key = normalizeKey(key);
95
+ this.stmt.delete.run(key);
96
+ return Promise.resolve();
97
+ }
98
+
99
+ clear(): Promise<void> {
100
+ this.stmt.clear.run();
101
+ return Promise.resolve();
102
+ }
103
+
104
+ length(): Promise<number> {
105
+ const row = this.stmt.count.get() as { count: number };
106
+ return Promise.resolve(row.count);
107
+ }
108
+
109
+ key(n: number): Promise<string | null> {
110
+ const row = this.stmt.keyByIndex.get(n) as { key: string } | undefined;
111
+ return Promise.resolve(row ? row.key : null);
112
+ }
113
+
114
+ keys(): Promise<string[]> {
115
+ const rows = this.stmt.keys.all() as Array<{ key: string }>;
116
+ return Promise.resolve(rows.map((r) => r.key));
117
+ }
118
+
119
+ setItems(items: Record<string, any>): Promise<void> {
120
+ this.db.exec('BEGIN IMMEDIATE');
121
+ try {
122
+ for (const [key, value] of Object.entries(items)) {
123
+ const normalizedKey = normalizeKey(key);
124
+ const coerced = value ?? null;
125
+ this.stmt.insert.run(normalizedKey, serializeSync(coerced));
126
+ }
127
+ this.db.exec('COMMIT');
128
+ } catch (e) {
129
+ try {
130
+ this.db.exec('ROLLBACK');
131
+ } catch (rollbackError) {
132
+ // If ROLLBACK fails, attach it as cause to the original error
133
+ // This preserves the root cause while providing full context
134
+ if (e instanceof Error && rollbackError instanceof Error) {
135
+ (e as Error & { cause?: Error }).cause = rollbackError;
136
+ }
137
+ }
138
+ throw e;
139
+ }
140
+ return Promise.resolve();
141
+ }
142
+
143
+ getItems(keys: string[]): Promise<Record<string, any>> {
144
+ if (keys.length === 0) {
145
+ return Promise.resolve({});
146
+ }
147
+ const keysJson = JSON.stringify(keys);
148
+ const rows = this.stmt.getItems.all(keysJson) as Array<{ key: string; value: string }>;
149
+ const result: Record<string, any> = {};
150
+ for (const row of rows) {
151
+ result[row.key] = deserialize(row.value);
152
+ }
153
+ return Promise.resolve(result);
154
+ }
155
+
156
+ /**
157
+ * Close the database connection. Useful for cleanup in tests.
158
+ */
159
+ close(): void {
160
+ this.db.close();
161
+ }
162
+ }
@@ -14,13 +14,7 @@
14
14
  // See the License for the specific language governing permissions and
15
15
  // limitations under the License.
16
16
 
17
- import executeCallback from 'localforage/src/utils/executeCallback';
18
- import getCallback from 'localforage/src/utils/getCallback';
19
- import normalizeKey from 'localforage/src/utils/normalizeKey';
20
- import serializer from 'localforage/src/utils/serializer';
21
-
22
- const serialize = serializer['serialize'];
23
- const deserialize = serializer['deserialize'];
17
+ import { chainCallback, getCallback, normalizeKey, serialize, deserialize } from './helpers';
24
18
 
25
19
  interface DbInfo {
26
20
  keyPrefix: string;
@@ -92,7 +86,7 @@ function clear(callback?: (err: any) => void): Promise<void> {
92
86
  }
93
87
  });
94
88
 
95
- executeCallback(promise, callback);
89
+ chainCallback(promise, callback);
96
90
  return promise;
97
91
  }
98
92
 
@@ -111,13 +105,13 @@ function getItem<T>(key: string, callback?: (err: any, value: T | null) => void)
111
105
  return null;
112
106
  });
113
107
 
114
- executeCallback(promise, callback);
108
+ chainCallback(promise, callback);
115
109
  return promise;
116
110
  }
117
111
 
118
112
  function iterate<T, U>(
119
113
  iterator: IteratorCallback<T, U>,
120
- callback?: (err: any, result: U | undefined) => void
114
+ callback?: (err: any, result: U | undefined) => void,
121
115
  ): Promise<U | undefined> {
122
116
  const self = this;
123
117
 
@@ -148,7 +142,7 @@ function iterate<T, U>(
148
142
  return;
149
143
  });
150
144
 
151
- executeCallback(promise, callback);
145
+ chainCallback(promise, callback);
152
146
  return promise;
153
147
  }
154
148
 
@@ -171,7 +165,7 @@ function key(n: number, callback?: (err: any, key: string | null) => void): Prom
171
165
  return result;
172
166
  });
173
167
 
174
- executeCallback(promise, callback);
168
+ chainCallback(promise, callback);
175
169
  return promise;
176
170
  }
177
171
 
@@ -190,7 +184,7 @@ function keys(callback?: (err: any, keys: string[]) => void): Promise<string[]>
190
184
  return keys;
191
185
  });
192
186
 
193
- executeCallback(promise, callback);
187
+ chainCallback(promise, callback);
194
188
  return promise;
195
189
  }
196
190
 
@@ -201,7 +195,7 @@ function length(callback?: (err: any, numberOfKeys: number) => void): Promise<nu
201
195
  return keys.length;
202
196
  });
203
197
 
204
- executeCallback(promise, callback);
198
+ chainCallback(promise, callback);
205
199
  return promise;
206
200
  }
207
201
 
@@ -210,7 +204,7 @@ function removeItem(key: string, callback?: (err: any) => void): Promise<void> {
210
204
  const promise = this.ready().then(function () {
211
205
  sessionStorage.removeItem(dbInfo.keyPrefix + key);
212
206
  });
213
- executeCallback(promise, callback);
207
+ chainCallback(promise, callback);
214
208
  return promise;
215
209
  }
216
210
 
@@ -225,29 +219,29 @@ async function setItem<T>(key: string, value: T, callback?: (err: any, value: T)
225
219
  dbInfo.serializer.serialize(value, (serializedValue: string, error: any) => {
226
220
  if (error) {
227
221
  reject(error);
228
- executeCallback(Promise.reject(error), callback);
222
+ chainCallback(Promise.reject(error), callback);
229
223
  return;
230
224
  }
231
225
 
232
226
  try {
233
227
  sessionStorage.setItem(dbInfo.keyPrefix + key, serializedValue);
234
228
  resolve(originalValue);
235
- executeCallback(Promise.resolve(originalValue), callback);
229
+ chainCallback(Promise.resolve(originalValue), callback);
236
230
  } catch (e: any) {
237
231
  if (e.name === 'QuotaExceededError' || e.name === 'NS_ERROR_DOM_QUOTA_REACHED') {
238
232
  console.error('Your sessionStorage capacity is used up.');
239
233
  }
240
234
  reject(e);
241
- executeCallback(Promise.reject(e), callback);
235
+ chainCallback(Promise.reject(e), callback);
242
236
  }
243
237
  });
244
238
  });
245
239
  }
246
240
 
247
- function dropInstance(options: any, callback?: (err: any) => void): Promise<void> {
248
- callback = getCallback.apply(this, arguments as any);
241
+ function dropInstance(this: any, ...args: any[]): Promise<void> {
242
+ const callback = getCallback(...args);
249
243
 
250
- options = (typeof options !== 'function' && options) || {};
244
+ let options = (typeof args[0] !== 'function' && args[0]) || {};
251
245
  if (!options.name) {
252
246
  const currentConfig = this.config();
253
247
  options.name = options.name || currentConfig.name;
@@ -275,7 +269,7 @@ function dropInstance(options: any, callback?: (err: any) => void): Promise<void
275
269
  });
276
270
  }
277
271
 
278
- executeCallback(promise, callback);
272
+ chainCallback(promise, callback);
279
273
  return promise;
280
274
  }
281
275
 
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Storage driver interface for key-value persistence.
3
+ * @public
4
+ */
5
+ export interface StorageDriver {
6
+ ready(): Promise<void>;
7
+ setItem(key: string, value: any): Promise<any>;
8
+ getItem(key: string): Promise<any>;
9
+ removeItem(key: string): Promise<void>;
10
+ clear(): Promise<void>;
11
+ length(): Promise<number>;
12
+ key(keyIndex: number): Promise<string | null>;
13
+ keys(): Promise<string[]>;
14
+ setItems?(items: Record<string, any>): Promise<void>;
15
+ getItems?(keys: string[]): Promise<Record<string, any>>;
16
+ debouncedSetItems?: {
17
+ (items: Record<string, any>): Promise<void>;
18
+ flush?: () => void;
19
+ };
20
+ }
21
+
22
+ /**
23
+ * @public
24
+ */
25
+ export type StoreType = 'local' | 'session' | 'indexed' | 'in_memory' | 'node';
@@ -160,6 +160,15 @@ export function EventEmitter<T extends ClassConstructor>(Base: T) {
160
160
  eventsApi(triggerApi, this._events, name, undefined, args);
161
161
  return this;
162
162
  }
163
+
164
+ /**
165
+ * Subscribe to an event. Returns an unsubscribe function.
166
+ * Compatible with `useSyncExternalStore` and other store-style APIs.
167
+ */
168
+ subscribe(event: string, callback: EventCallback, context?: EventContext): () => void {
169
+ this.on(event, callback, context);
170
+ return () => this.off(event, callback, context);
171
+ }
163
172
  };
164
173
  }
165
174
 
package/src/helpers.ts CHANGED
@@ -7,6 +7,7 @@ import result from 'lodash-es/result';
7
7
  import { Model } from './model';
8
8
  import { Collection } from './collection';
9
9
  import { type SyncOptions, SyncOperation } from './types';
10
+ import type PersistentStorage from './storage';
10
11
 
11
12
  /**
12
13
  * Custom error for indicating timeouts
@@ -33,7 +34,7 @@ export function guid(): string {
33
34
  //
34
35
  export function inherits<T extends new (...args: any[]) => any>(
35
36
  protoProps: Record<string, any> | null,
36
- staticProps?: Record<string, any>
37
+ staticProps?: Record<string, any>,
37
38
  ): T {
38
39
  // eslint-disable-next-line @typescript-eslint/no-this-alias
39
40
  const parent = this;
@@ -90,19 +91,21 @@ export function getResolveablePromise(): ResolveablePromise {
90
91
  }) as ResolveablePromise;
91
92
 
92
93
  Object.assign(promise, wrapper);
94
+ // This `then` only mirrors settlement into the state flags. It must NOT
95
+ // rethrow on rejection: the derived promise is discarded, so rethrowing would
96
+ // surface as an unhandled rejection whenever the promise is rejected. The real
97
+ // consumer still awaits `promise` itself, which rejects normally.
93
98
  promise.then(
94
- function (v) {
99
+ function () {
95
100
  promise.isResolved = true;
96
101
  promise.isPending = false;
97
102
  promise.isRejected = false;
98
- return v;
99
103
  },
100
- function (e) {
104
+ function () {
101
105
  promise.isResolved = false;
102
106
  promise.isPending = false;
103
107
  promise.isRejected = true;
104
- throw e;
105
- }
108
+ },
106
109
  );
107
110
  return promise;
108
111
  }
@@ -112,12 +115,19 @@ export function urlError(): never {
112
115
  throw new Error('A "url" property or function must be specified');
113
116
  }
114
117
 
115
- // Wrap an optional error callback with a fallback error event.
116
- export function wrapError(model: Model<any> | Collection<any>, options: any): void {
118
+ // Wrap an optional error callback with a fallback error event. When a
119
+ // `promise` is supplied (the `{ promise: true }` fetch/save path), the error is
120
+ // also forwarded to `promise.reject` so the returned promise settles on failure.
121
+ export function wrapError(
122
+ model: Model<any> | Collection<any>,
123
+ options: any,
124
+ promise?: ResolveablePromise,
125
+ ): void {
117
126
  const error = options.error;
118
127
  options.error = function (resp: any) {
119
128
  if (error) error.call(options.context, model, resp, options);
120
129
  model.trigger('error', model, resp, options);
130
+ promise && promise.reject && promise.reject(resp);
121
131
  };
122
132
  }
123
133
 
@@ -130,9 +140,46 @@ const methodMap: Record<string, string> = {
130
140
  read: 'GET',
131
141
  };
132
142
 
143
+ // Tracks the classes we've already warned about, so the `browserStorage`
144
+ // deprecation notice is logged at most once per class rather than once per
145
+ // instance (a collection can resolve storage once per contained model).
146
+ const warnedBrowserStorage = new WeakSet<object>();
147
+
148
+ /**
149
+ * Emit a one-time-per-class deprecation warning nudging downstream code from
150
+ * the `browserStorage` alias to the canonical `storage` accessor.
151
+ */
152
+ export function warnBrowserStorageDeprecation(obj: Model<any> | Collection<any>): void {
153
+ const ctor = obj?.constructor;
154
+ if (!ctor || warnedBrowserStorage.has(ctor)) return;
155
+ warnedBrowserStorage.add(ctor);
156
+ const name = (ctor as { name?: string }).name;
157
+ console.warn(
158
+ `Skeletor: \`browserStorage\` is deprecated and will be removed in a future major version; ` +
159
+ `use \`storage\` instead${name ? ` (on \`${name}\`)` : ''}.`,
160
+ );
161
+ }
162
+
163
+ /**
164
+ * Return the PersistentStorage instance configured on `obj`, checking both
165
+ * the canonical `storage` accessor and the deprecated `browserStorage` alias
166
+ * so that subclasses that still override `get browserStorage()` continue to work.
167
+ * @public
168
+ */
169
+ export function getStorage(obj: Model<any> | Collection<any> | null | undefined): PersistentStorage | undefined {
170
+ if (!obj) return undefined;
171
+ const storage = result(obj, 'storage') as PersistentStorage | undefined;
172
+ if (storage != null) return storage;
173
+ // Fall back to the deprecated alias; warn only when it actually supplies the
174
+ // store, so storage-less models/collections don't trip the notice.
175
+ const legacy = result(obj, 'browserStorage') as PersistentStorage | undefined;
176
+ if (legacy != null) warnBrowserStorageDeprecation(obj);
177
+ return legacy;
178
+ }
179
+
133
180
  export function getSyncMethod(model: Model | Collection<any>): typeof sync & { __name__?: string } {
134
- const store = result(model, 'browserStorage') || result((model as Model).collection, 'browserStorage');
135
- return store ? (store as any).sync() : sync;
181
+ const store = getStorage(model) || getStorage((model as Model).collection);
182
+ return store ? store.sync() : sync;
136
183
  }
137
184
 
138
185
  /**
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Node.js entry point for @converse/skeletor.
3
+ * Re-exports everything from the browser entry and additionally
4
+ * exports the NodeSQLiteStorage LocalForage implementation.
5
+ */
6
+ import PersistentStorage from './storage';
7
+ import { NodeSQLiteStorage } from './drivers/nodeSQLiteStorage';
8
+
9
+ // Register the SQLite driver so `new PersistentStorage(id, 'node')` resolves it.
10
+ // The browser builds leave this unset, keeping the node-only driver out of
11
+ // their bundles entirely.
12
+ PersistentStorage.nodeStorage = NodeSQLiteStorage;
13
+
14
+ export * from './index';
15
+ export { default } from './index';
16
+ export { NodeSQLiteStorage };
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /* global global */
2
2
  import { EventEmitter } from './eventemitter';
3
- import BrowserStorage from './storage';
3
+ import PersistentStorage from './storage';
4
4
  import { Collection } from './collection';
5
5
  import { Model } from './model';
6
6
  import { sync } from './helpers';
@@ -54,12 +54,20 @@ skeletor.noConflict = noConflict;
54
54
  root.Skeletor = skeletor;
55
55
  export default skeletor;
56
56
 
57
- export { noConflict, Collection, EventEmitter, Model, BrowserStorage, sync };
57
+ /**
58
+ * @deprecated Use `PersistentStorage` instead.
59
+ * @public
60
+ */
61
+ const BrowserStorage = PersistentStorage;
62
+ export { noConflict, Collection, EventEmitter, Model, PersistentStorage, BrowserStorage, sync };
63
+ export { getStorage } from './helpers';
58
64
 
59
- export type { LocalForageWithExtensions } from './storage';
65
+ export type { StorageDriver, StoreType } from './drivers/types';
60
66
  export type {
61
67
  ClassConstructor,
62
68
  CollectionOptions,
69
+ ComputedProperties,
70
+ ComputedProperty,
63
71
  Comparator,
64
72
  EventCallback,
65
73
  EventCallbackMap,