@converse/skeletor 3.0.1 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,28 @@
3
3
  [![XMPP Chat](https://conference.conversejs.org/muc_badge/discuss@conference.conversejs.org)](https://inverse.chat/#converse/room?jid=discuss@conference.conversejs.org)
4
4
  [![CI Tests](https://github.com/conversejs/skeletor/actions/workflows/karma-tests.yml/badge.svg)](https://github.com/conversejs/skeletor/actions/workflows/karma-tests.yml)
5
5
 
6
- Skeletor is a modernization of [Backbone](http://backbonejs.org)'s Models and Collections, which are used for managing state.
6
+ Skeletor is a lightweight, TypeScript-first reactive data library.
7
+
8
+ It lets you define typed models and react to changes with events or subscriptions,
9
+ while persisting to IndexedDB, localStorage, sessionStorage, SQLite (Node) or a REST API.
10
+
11
+ Skeletor is a modernized rewrite of [Backbone](http://backbonejs.org)'s Models and Collections,
12
+ without jQuery, without Underscore, and without Views or Routing. If you know Backbone, everything
13
+ transfers directly. If you don't, there's nothing to unlearn.
14
+
15
+ Skeletor powers [Converse.js](https://conversejs.org), a full-featured open-source XMPP chat client.
16
+
17
+ ## Why Skeletor?
18
+
19
+ - **Reactive models** — set an attribute, get a `change` event. Subscribe with a callback and an unsubscribe function. Works with any UI layer.
20
+ - **Direct attribute access** — `model.attrs.name = 'Bob'` fires change events, no boilerplate needed.
21
+ - **Computed properties** — declare derived values with explicit dependencies; they cache, recalculate, and fire `change` events automatically.
22
+ - **Store-style subscriptions** — `subscribe()` returns an unsubscribe function, compatible with React's `useSyncExternalStore` and similar APIs.
23
+ - **Built-in persistence** — IndexedDB, localStorage, sessionStorage, SQLite (Node), and REST out of the box.
24
+ - **TypeScript-first** — full type definitions, generic model attributes, typed computed properties.
25
+ - **No jQuery, no Underscore** — native browser APIs and [lodash-es](https://lodash.com) with individual imports for tree-shaking.
26
+ - **Works anywhere** — browser, Node.js (22+), Web Workers. ESM + CJS builds included.
27
+ - **Backbone-compatible** — `get`, `set`, events, collections: all there. Drop-in replacement for Backbone's data layer.
7
28
 
8
29
  ## Installation
9
30
 
@@ -11,44 +32,315 @@ Skeletor is a modernization of [Backbone](http://backbonejs.org)'s Models and Co
11
32
  npm install @converse/skeletor
12
33
  ```
13
34
 
35
+ ## Quick Start
36
+
37
+ ### Model
38
+
39
+ ```ts
40
+ import { Model } from '@converse/skeletor';
41
+
42
+ interface UserAttrs {
43
+ firstName: string;
44
+ lastName: string;
45
+ active: boolean;
46
+ }
47
+
48
+ class User extends Model<UserAttrs> {
49
+ get defaults() {
50
+ return { firstName: '', lastName: '', active: false };
51
+ }
52
+
53
+ get computed() {
54
+ return {
55
+ fullName: {
56
+ deps: ['firstName', 'lastName'],
57
+ fn: (model: User) => `${model.get('firstName')} ${model.get('lastName')}`,
58
+ },
59
+ };
60
+ }
61
+ }
62
+
63
+ const user = new User({ firstName: 'Alice', lastName: 'Smith' });
64
+
65
+ // Read — three equivalent ways
66
+ user.get('firstName'); // → 'Alice'
67
+ user.attrs.firstName; // → 'Alice'
68
+ user.get('fullName'); // → 'Alice Smith' (computed — cached, never persisted)
69
+
70
+ // Write — fires change events
71
+ user.attrs.firstName = 'Bob'; // triggers 'change:firstName' and 'change:fullName'
72
+ user.set('active', true); // triggers 'change:active'
73
+
74
+ // React to changes
75
+ user.on('change:fullName', (model, value) => {
76
+ console.log('Name is now', value);
77
+ });
78
+
79
+ // Store-style subscription (returns unsubscribe function)
80
+ const unsub = user.subscribe((model, changed) => {
81
+ console.log('Changed attrs:', changed);
82
+ });
83
+ unsub(); // clean up
84
+ ```
85
+
86
+ ### Collection
87
+
88
+ ```ts
89
+ import { Model, Collection } from '@converse/skeletor';
90
+
91
+ class User extends Model {}
92
+
93
+ class Users extends Collection {
94
+ get model() {
95
+ return User;
96
+ }
97
+ get url() {
98
+ return '/api/users';
99
+ }
100
+ }
101
+
102
+ const users = new Users([
103
+ { id: 1, name: 'Alice', active: true },
104
+ { id: 2, name: 'Bob', active: false },
105
+ ]);
106
+
107
+ users.add({ id: 3, name: 'Carol', active: true });
108
+
109
+ const active = users.filter((u) => u.get('active'));
110
+ const names = users.pluck('name'); // → ['Alice', 'Bob', 'Carol']
111
+
112
+ // Subscribe to structural changes (fires once per operation, not per model)
113
+ const unsub = users.subscribe((collection) => {
114
+ console.log('collection changed, length:', collection.length);
115
+ });
116
+
117
+ // Load from the server
118
+ await users.fetch();
119
+ ```
120
+
121
+ ### Local Persistence
122
+
123
+ Skeletor can persist models and collections to IndexedDB, localStorage, sessionStorage, or SQLite (Node.js) with no extra packages.
124
+
125
+ #### Manual persistence
126
+
127
+ The traditional Backbone-style API: set storage once, call `save()`/`fetch()` explicitly.
128
+
129
+ ```ts
130
+ import { Model, PersistentStorage } from '@converse/skeletor';
131
+
132
+ class Settings extends Model {
133
+ initialize() {
134
+ this.storage = new PersistentStorage('app-settings', 'local');
135
+ }
136
+ }
137
+
138
+ const settings = new Settings({ id: 'main' });
139
+ await settings.fetch(); // load from localStorage
140
+ settings.set('theme', 'dark');
141
+ await settings.save(); // write to localStorage
142
+ ```
143
+
144
+ #### Automatic persistence (`autoSync`)
145
+
146
+ Opt in once, then every write persists automatically — no `save()` calls needed.
147
+
148
+ ```ts
149
+ import { Model, PersistentStorage } from '@converse/skeletor';
150
+
151
+ class Settings extends Model {
152
+ get autoSync() {
153
+ return true;
154
+ }
155
+
156
+ initialize() {
157
+ this.storage = new PersistentStorage('app-settings', 'local');
158
+ }
159
+ }
160
+
161
+ const settings = new Settings({ id: 'main' });
162
+ await settings.initialized; // resolves when prior data has been loaded
163
+
164
+ settings.attrs.theme = 'dark'; // persisted automatically (debounced)
165
+ settings.set('lang', 'en'); // also auto-saved
166
+
167
+ // Pass { noAutoSave: true } to suppress persistence for a specific set() call
168
+ settings.set('transient', true, { noAutoSave: true });
169
+ ```
170
+
171
+ Pending writes are debounced (see `autoSyncDelay`, default 100ms) and flushed when the
172
+ page is hidden (`visibilitychange`) or unloaded (`pagehide`).
173
+
174
+ When `autoSync` is off, `initialized` is `undefined`, but `await model.initialized` is still safe (awaiting `undefined` is a no-op), so code can await it uniformly.
175
+
176
+ On a `Collection`, `autoSync` is **hydrate-only**: the collection loads its stored
177
+ state on construction but does not auto-save on `add`/`remove`/`reset`. To persist
178
+ the contents, enable `autoSync` on the contained model class — each model then
179
+ writes its own record.
180
+
181
+ Supported backends: `'local'` (localStorage), `'session'` (sessionStorage), `'indexed'` (IndexedDB), `'memory'`, `'node'` (SQLite, Node 22+).
182
+
183
+ > [!NOTE]
184
+ > **Durability on unload is best-effort, not guaranteed.** The flush on
185
+ > `visibilitychange`/`pagehide` is synchronous, but the underlying write may not be:
186
+ >
187
+ > - With **synchronous** backends (`'local'`, `'session'`) the flush completes before
188
+ > the page tears down, so the last write is safe.
189
+ > - With **asynchronous** backends (`'indexed'`) the flush only _starts_ the write; the
190
+ > browser can kill the page before an IndexedDB transaction commits, losing the most
191
+ > recent debounced change. The browser gives no primitive to await a write during
192
+ > unload, so this is inherent rather than a bug.
193
+ >
194
+ > `visibilitychange → hidden` fires earlier and far more reliably than `pagehide`
195
+ > (especially on mobile, where backgrounded tabs are killed without `pagehide`), so it
196
+ > is the real save point. To shrink the loss window further with an async backend, lower
197
+ > `autoSyncDelay`. If a write must never be lost, call `await model.save()` explicitly
198
+ > rather than relying on the unload flush.
199
+
200
+ ### Events
201
+
202
+ ```ts
203
+ import { EventEmitter } from '@converse/skeletor';
204
+
205
+ class Store extends EventEmitter {}
206
+
207
+ const store = new Store();
208
+ store.on('update', (data) => console.log('Updated:', data));
209
+ store.trigger('update', { key: 'value' });
210
+
211
+ // Listen to another object's events (auto-cleaned up with stopListening)
212
+ const view = new EventEmitter();
213
+ view.listenTo(store, 'update', (data) => console.log('View saw:', data));
214
+ view.stopListening(); // removes all listeners set up via listenTo
215
+
216
+ // subscribe() returns an unsubscribe function
217
+ const unsub = store.subscribe('update', (data) => console.log(data));
218
+ unsub();
219
+ ```
220
+
221
+ ### Integration with React
222
+
223
+ `subscribe()` is directly compatible with React's `useSyncExternalStore`:
224
+
225
+ ```ts
226
+ import { useSyncExternalStore } from 'react';
227
+
228
+ function UserName({ user }) {
229
+ const attrs = useSyncExternalStore(
230
+ (cb) => user.subscribe(cb), // subscribe — returns unsub
231
+ () => user.toJSON() // getSnapshot
232
+ );
233
+ return <span>{attrs.firstName}</span>;
234
+ }
235
+ ```
236
+
237
+ ## Features at a Glance
238
+
239
+ | Export | What it provides |
240
+ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
241
+ | `Model` | `get`/`set`, `attrs` proxy, `computed` properties, change tracking, validation, server sync |
242
+ | `Collection` | Full array API plus `where`, `findWhere`, `pluck`, `groupBy`, `keyBy`, `countBy`, `sortBy` |
243
+ | `EventEmitter` | `on`/`off`/`trigger`/`once`, `listenTo`/`stopListening`, `subscribe()` returning an unsubscribe function |
244
+ | `PersistentStorage` | IndexedDB, localStorage, sessionStorage, SQLite (Node), and in-memory backends. `autoSync` for transparent auto-save and auto-hydrate. |
245
+ | `sync` | Low-level Fetch-based HTTP function (override for custom transports) |
246
+
247
+ ## Design philosophy
248
+
249
+ Skeletor is a **headless data layer**, not an application-state framework. It owns your _domain model_ (typed models
250
+ and collections, their relationships, persistence, and server sync) and stays out of your view layer.
251
+ Bind it to whatever renders your UI: [Lit](https://lit.dev), React (via [`useSyncExternalStore`](#integration-with-react)), or plain DOM.
252
+
253
+ That scope is deliberate. Skeletor isn't trying to compete with signal libraries, Zustand, or Redux for general UI state.
254
+ What it's uniquely good at is giving a real, long-lived application a _typed, persistent, promise-capable_ data layer it
255
+ can adopt **incrementally** (above all an existing Backbone codebase that can't afford a rewrite).
256
+
257
+ Principles that follow from that:
258
+
259
+ - **Incremental over big-bang.** Every feature is opt-in and backwards-compatible.
260
+ Adopt `attrs`, `computed`, `subscribe`, or `autoSync` one model or one callsite at a time; nothing forces a migration.
261
+ - **Promise-capable, callback-compatible.** Async operations return promises, while Backbone-style `success`/`error` callbacks and events keep working.
262
+ New code can `await`, old code doesn't break.
263
+ - **Derive, don't duplicate.** `computed` expresses derived values once, with caching and automatic change events.
264
+ - **Headless and framework-agnostic.** No views, no router, no framework assumptions. The reactive surface is `change` events and `subscribe()`.
265
+ - **Honest about tradeoffs.** Where a guarantee can't be made (e.g. async persistence on page unload)
266
+ the docs say so plainly rather than implying durability that doesn't exist (see the note under [Local Persistence](#local-persistence)).
267
+
268
+ ### When to use it
269
+
270
+ - You're modernizing a Backbone-era app and want a typed, promise-capable data layer without a rewrite.
271
+ - You want a headless store with built-in persistence that binds to any UI framework.
272
+ - Your state is genuinely _domain data_ — entities with identity, relationships, and a persisted lifecycle.
273
+
274
+ ### When to reach for something else
275
+
276
+ - You want reactivity that auto-tracks dependencies and updates individual bindings — that's what signals give you.
277
+ Skeletor notifies per-attribute (`change:foo`), but you wire each reaction yourself, and it re-renders at component granularity.
278
+ - Your "state" is mostly ephemeral view state (form inputs, toggles, hover) — keep that in your view layer.
279
+ - You need time-travel, structural sharing, or a strict immutable single-store architecture — that's Redux's territory.
280
+
281
+ ### Direction
282
+
283
+ Skeletor is being modernized from within rather than replaced. The trajectory is a
284
+ **promise-first core with events and callbacks as a compatibility shim**, and a reactivity model that bridges cleanly to signals.
285
+ The aim is that consumers never face a migration. The library modernizes underneath them while the public surface stays stable,
286
+ and the Backbone compatibility that exists today isn't sacrificed to get there.
287
+
288
+ ## Migrating from Backbone
289
+
290
+ If you have an existing Backbone project, you can migrate the data layer incrementally:
291
+
292
+ 1. Install Skeletor: `npm install @converse/skeletor`
293
+ 2. Replace `import Backbone from 'backbone'` with named imports: `import { Model, Collection } from '@converse/skeletor'`
294
+ 3. Replace `Backbone.Model.extend({...})` with `class MyModel extends Model { ... }`
295
+ 4. Fix the handful of removed or renamed methods listed below
296
+ 5. Keep using Backbone for Views, Router, and History — they are unaffected
297
+
298
+ Your Views and Router don't need to change at all. Skeletor models and collections emit the same events Backbone does.
299
+
14
300
  ## Changes from Backbone
15
301
 
16
- We've made big, backwards incompatible changes in version 1.0.0:
17
-
18
- - Removed the old `View`
19
- - Removed the old `Events` constructor function and instead added the `EventsEmitter` mixin class.
20
- - Removed the `Router` and `History` classes.
21
- - All other types (e.g. `Model`, `Collection`) are now ES6 classes.
22
-
23
- ### Sekeletor adds the following changes to Backbone
24
-
25
- * Rewritten in TypeScript
26
- * Removes the dependency on jQuery
27
- * Replaces [underscore](http://underscorejs.org) with [lodash](https://lodash.com)
28
- * Imports lodash methods individually to allow for tree-shaking
29
- * Uses the native browser API instead of lodash whereever possible
30
- * Drops support for older browsers (including IE) and uses ES6+ language features
31
- * Splits models and collections into separate modules
32
- * Adds the possibility to returns promises for asynchronous operations
33
-
34
- ### Other backwards incompatible changes
35
-
36
- * Collection.prototype.forEach no longer returns the items being iterated over.
37
- If you need that, use `map` instead.
38
- * The `chain`, `clone` and `escape` methods on Models have been removed.
39
- * The `clone` method has also been removed from Collections
40
- * The `inject`, `foldl` and `foldr` methods on Collections has been removed. You can use `reduce` instead.
41
- * Removed the `sample`, `take`, `tail` and `initial` method on Collections.
42
- * Removed the `without`, `reject` and `select` methods on Collections, use `filter`.
43
- * Removed the `.extend()` method on `Model` and `Collection`.
44
- * Models and Collections should be defined via `class .. extends` syntax.
45
-
46
- #### Changes due to using Lodash instead of Underscore
47
-
48
- 1. Use `drop` instead of `rest`.
49
- 2. `indexBy` is called `keyBy`
50
- 3. Use `invokeMap` for collections instead of `invoke`.
51
- 4. Use `includes` instead of `contains`
52
- 5. The `partition` and `invokeMap` methods have been removed.
302
+ ### Modernizations
303
+
304
+ - Rewritten in TypeScript with full type definitions
305
+ - Removed the dependency on jQuery
306
+ - Replaced [underscore](http://underscorejs.org) with [lodash-es](https://lodash.com), with individual imports for tree-shaking
307
+ - Uses native browser APIs instead of lodash wherever possible
308
+ - Drops support for older browsers (including IE); requires ES6+
309
+ - All types (`Model`, `Collection`) are ES6 classes — use `class extends` instead of `.extend()`
310
+ - Adds `EventEmitter` mixin class (replaces the old `Events` constructor function)
311
+ - Async operations return Promises
312
+ - ESM build available alongside CJS
313
+ - `attrs` proxy for direct reactive attribute access
314
+ - `computed` properties with caching and automatic change events
315
+ - `subscribe()` returning an unsubscribe function on all reactive objects
316
+
317
+ ### What was removed
318
+
319
+ - `View` and `ElementView` — manage your UI separately
320
+ - `Router` and `History` — use the browser's History API or a dedicated router library
321
+ - `.extend()` static method — use `class MyModel extends Model` instead
322
+ - `clone()` on Model — use `new MyModel(model.toJSON())` instead
323
+ - `chain()` and `escape()` on Model
324
+ - `inject`, `foldl`, `foldr` on Collection — use `reduce` instead
325
+ - `sample`, `take`, `tail`, `initial` on Collection
326
+ - `without`, `reject`, `select` on Collection — use `filter` instead
327
+ - `partition` and `invokeMap` on Collection
328
+
329
+ ### Method renames (Underscore → Lodash)
330
+
331
+ | Old (Underscore) | New (Lodash) |
332
+ | ---------------- | -------------------------------------- |
333
+ | `rest` | `drop` |
334
+ | `indexBy` | `keyBy` |
335
+ | `invoke` | `invokeMap` (then removed — use `map`) |
336
+ | `contains` | `includes` |
337
+
338
+ ### Other behavioural changes
339
+
340
+ - `Collection.prototype.forEach` no longer returns the iterated items. Use `map` instead.
341
+ - `Model.prototype.set` returns `null` (not `false`) when validation fails.
342
+ - `Collection.prototype.create` returns `null` (not `boolean`) on failure.
343
+
344
+ ---
53
345
 
54
346
  ![](https://raw.githubusercontent.com/conversejs/skeletor/master/images/skeletor2.jpg)