@converse/skeletor 3.0.0 → 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/README.md +330 -38
- package/dist/skeletor.d.ts +193 -56
- package/dist/skeletor.esm.js +12328 -0
- package/dist/skeletor.esm.js.map +1 -0
- package/dist/skeletor.js +740 -456
- package/dist/skeletor.js.map +1 -0
- package/dist/skeletor.min.js +6 -19
- package/dist/skeletor.node.js +12494 -0
- package/dist/skeletor.node.mjs +12481 -0
- package/package.json +32 -5
- package/src/autosync.ts +156 -0
- package/src/collection.ts +140 -35
- package/src/drivers/helpers.ts +61 -0
- package/src/drivers/nodeSQLiteStorage.ts +162 -0
- package/src/drivers/sessionStorage.ts +16 -22
- package/src/drivers/types.ts +25 -0
- package/src/eventemitter.ts +9 -0
- package/src/helpers.ts +57 -10
- package/src/index.node.ts +8 -0
- package/src/index.ts +11 -3
- package/src/model.ts +254 -13
- package/src/storage.ts +78 -38
- package/src/types.ts +34 -9
- package/src/utils/events.ts +6 -6
package/README.md
CHANGED
|
@@ -3,7 +3,28 @@
|
|
|
3
3
|
[](https://inverse.chat/#converse/room?jid=discuss@conference.conversejs.org)
|
|
4
4
|
[](https://github.com/conversejs/skeletor/actions/workflows/karma-tests.yml)
|
|
5
5
|
|
|
6
|
-
Skeletor is a
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
-
|
|
19
|
-
- Removed the
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|

|