seqda 1.1.3 → 2.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/.eslintrc.cjs ADDED
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ /* eslint-disable no-magic-numbers */
4
+
5
+ module.exports = {
6
+ 'env': {
7
+ 'browser': true,
8
+ 'commonjs': true,
9
+ 'es2021': true,
10
+ },
11
+ 'extends': [
12
+ 'eslint:recommended',
13
+ ],
14
+ 'parserOptions': {
15
+ 'ecmaVersion': 'latest',
16
+ 'sourceType': 'module',
17
+ },
18
+ 'plugins': [
19
+ '@spothero/eslint-plugin-spothero',
20
+ ],
21
+ 'rules': {
22
+ '@spothero/spothero/ternary-parentheses': 'error',
23
+ 'arrow-parens': 'error',
24
+ 'arrow-spacing': [ 'error', { before: true, after: true } ],
25
+ 'block-scoped-var': 'warn',
26
+ 'block-spacing': 'error',
27
+ 'brace-style': [ 'error', '1tbs' ],
28
+ 'camelcase': 'warn',
29
+ 'comma-dangle': [ 'error', 'always-multiline' ],
30
+ 'comma-spacing': [ 'error', { before: false, after: true } ],
31
+ 'comma-style': [ 'error', 'last' ],
32
+ 'curly': [ 'error', 'multi-or-nest', 'consistent' ],
33
+ 'default-case-last': 'error',
34
+ 'default-param-last': 'error',
35
+ 'eqeqeq': [ 'error', 'smart' ],
36
+ 'func-call-spacing': [ 'error', 'never' ],
37
+ 'guard-for-in': 'error',
38
+ 'implicit-arrow-linebreak': [ 'error', 'beside' ],
39
+ 'indent': [ 'error', 2, { 'SwitchCase': 1 } ],
40
+ 'jsx-quotes': [ 'error', 'prefer-double' ],
41
+ 'key-spacing': [ 'error', { beforeColon: false, afterColon: true, mode: 'minimum', 'align': 'value' } ],
42
+ 'keyword-spacing': [ 'error', { before: true, after: true } ],
43
+ 'linebreak-style': [ 'error', 'unix' ],
44
+ 'lines-between-class-members': 'error',
45
+ 'max-classes-per-file': [ 'error', 3 ],
46
+ 'new-cap': [ 'error', { 'properties': false } ],
47
+ 'new-parens': 'error',
48
+ 'no-array-constructor': 'warn',
49
+ 'no-caller': 'error',
50
+ 'no-confusing-arrow': 'error',
51
+ 'no-empty': 'warn',
52
+ 'no-eq-null': 0,
53
+ 'no-eval': 'error',
54
+ 'no-extend-native': 'error',
55
+ 'no-extra-label': 'error',
56
+ 'no-floating-decimal': 'error',
57
+ 'no-global-assign': 'error',
58
+ 'no-implied-eval': 'error',
59
+ 'no-labels': 'error',
60
+ 'no-lone-blocks': 'warn',
61
+ 'no-loop-func': 0,
62
+ 'no-magic-numbers': [ 'warn', { ignoreArrayIndexes: true, ignoreDefaultValues: true, ignore: [ -1, 0, 1, 2, 16, 32, 64, 128, 256, 1024, 2048, 200, 301, 302, 400, 401, 404, 500 ] } ],
63
+ 'no-nested-ternary': 'error',
64
+ 'no-param-reassign': 'error',
65
+ 'no-promise-executor-return': 'error',
66
+ 'no-return-assign': 'error',
67
+ 'no-sequences': 'error',
68
+ 'no-shadow': 0,
69
+ 'no-throw-literal': 'warn',
70
+ 'no-trailing-spaces': 'error',
71
+ 'no-unmodified-loop-condition': 'warn',
72
+ 'no-unreachable-loop': 'warn',
73
+ 'no-unreachable': 'warn',
74
+ 'no-unused-private-class-members': 'warn',
75
+ 'no-unused-vars': 'warn',
76
+ 'no-whitespace-before-property': 'error',
77
+ 'nonblock-statement-body-position': [ 'error', 'below' ],
78
+ 'one-var': [ 'error', 'never' ],
79
+ 'quotes': [ 'error', 'single' ],
80
+ 'radix': 'error',
81
+ 'rest-spread-spacing': [ 'error', 'never' ],
82
+ 'semi-spacing': [ 'error', { before: false, after: true } ],
83
+ 'semi-style': [ 'error', 'last' ],
84
+ 'semi': 'error',
85
+ 'space-before-blocks': 'error',
86
+ 'space-infix-ops': 'error',
87
+ 'space-unary-ops': [ 'error', { words: false, nonwords: false } ],
88
+ 'strict': 'error',
89
+ 'switch-colon-spacing': [ 'error', { before: false, after: true } ],
90
+ 'template-curly-spacing': 'error',
91
+ 'template-tag-spacing': 'error',
92
+ 'wrap-iife': [ 'error', 'inside' ],
93
+ 'yoda': 'error',
94
+ },
95
+ };
package/README.md CHANGED
@@ -23,7 +23,8 @@ There are no actions, dispatches, reducers, or selectors per-se. Instead, there
23
23
  In `seqda` there are a few key principles that will be mentioned throughout this document. Let's create a simple store to explain these principles and terminology:
24
24
 
25
25
  ```javascript
26
- const { createStore } = require('seqda');
26
+ import { createStore } from 'seqda';
27
+
27
28
  const MyStore = createStore({
28
29
  todos: { // This is a "scope"
29
30
  _: [], // This is the "default value" for this scope
@@ -84,52 +85,77 @@ const MyStore = createStore({
84
85
 
85
86
  // Methods go here
86
87
  }
87
- })
88
+ });
88
89
 
89
90
  // We can add a todo by calling our method
90
- // (notice that the "context") argments ({ get, set })
91
- // are provided internally by seqda
91
+ // (notice that the "context" arguments ({ get, set })
92
+ // are provided internally by seqda)
92
93
 
93
- MyStore.todos.add(/* todo */ { todo: 'Do things!', id: 1 });
94
+ MyStore.todos.add({ todo: 'Do things!', id: 1 });
94
95
 
95
96
  console.log(MyStore.getState());
96
-
97
- {
98
- "todos": [
99
- { "todo": "Do things!", "id": 1 },
100
- ],
101
- "config": {
102
- "configValue1": null,
103
- "configValue2": null,
104
- 'userConfig": {
105
- "firstName": '',
106
- "lastName": '',
107
- }
108
- }
109
- }
97
+ // {
98
+ // "todos": [
99
+ // { "todo": "Do things!", "id": 1 }
100
+ // ],
101
+ // "config": {
102
+ // "configValue1": null,
103
+ // "configValue2": null,
104
+ // "userConfig": {
105
+ // "firstName": "",
106
+ // "lastName": ""
107
+ // }
108
+ // }
109
+ // }
110
110
  ```
111
111
 
112
- ## The entire store is frozen
112
+ ## Immutability
113
113
 
114
- In `seqda`, all values in the store are always frozen with `Object.freeze`. This ensures that your store stays immutable, and can only be updated with the provided scope methods. For example, if you try to set something directly on the returned state, it will fail:
114
+ In `seqda`, the store's internal state tree is frozen with `Object.freeze`. This ensures structural immutability the state can only be updated through scope methods via `set()`.
115
115
 
116
116
  ```javascript
117
117
  let state = MyStore.getState();
118
118
  state.setSomething = toAValue;
119
+ // TypeError: Cannot add property setSomething, object is not extensible
120
+ ```
121
+
122
+ **Important:** The freeze is *shallow* — it applies to the state tree nodes (objects and arrays at each path level) but does **not** deep-freeze objects stored as values inside those containers. For example, if you store an object inside an array scope, the array is frozen (you can't push/pop), but the object itself remains mutable:
119
123
 
120
- // throw new TypeError('Cannot add property setSomething, object is not extensible');
124
+ ```javascript
125
+ import { createStore } from 'seqda';
126
+
127
+ const store = createStore({
128
+ items: {
129
+ _: [],
130
+ add({ get, set }, item) {
131
+ set([...get(), item]);
132
+ },
133
+ get({ get }) {
134
+ return get();
135
+ },
136
+ },
137
+ });
138
+
139
+ let item = { name: 'test', mutable: true };
140
+ store.items.add(item);
141
+
142
+ let items = store.items.get();
143
+ items.push('fail'); // TypeError — array is frozen
144
+ items[0].name = 'modified'; // Works — item object is NOT frozen
121
145
  ```
122
146
 
147
+ This is by design. It keeps seqda lightweight and allows consumers to manage their own object immutability strategy (e.g., `Object.freeze` at the application level, or treating objects as immutable by convention).
148
+
123
149
  ## Method cache
124
150
 
125
151
  All scope methods in `seqda` are cached by default. For this reason, it is fine to have getters that contain complex logic and filtering.
126
152
 
127
- The cache is invalidated as soon as 1) the internal state for a scope is updated, or 2) the arguments to the method call change.
153
+ The cache is invalidated as soon as 1) the internal state for a scope is updated via `set()`, or 2) the arguments to the method call change.
128
154
 
129
155
  Let's see an example of this in action:
130
156
 
131
157
  ```javascript
132
- const { createStore } = require('seqda');
158
+ import { createStore } from 'seqda';
133
159
 
134
160
  const MyStore = createStore({
135
161
  citizens: {
@@ -142,18 +168,14 @@ const MyStore = createStore({
142
168
  _: [],
143
169
  get({ get }, stateName) {
144
170
  if (!stateName)
145
- return get(); // if no stateName was provided, then return all states
171
+ return get();
146
172
 
147
173
  return get().find((state) => (state.name === stateName));
148
174
  },
149
175
  getCitizensForState({ get, store }, stateName) {
150
- // First, get the state requested from the store
151
176
  let state = store.states.get(stateName);
152
-
153
- // Next get the citizens for this state
154
- // This is now cached, so as long as the
155
- // arguments (shortStateName) remain the
156
- // same, we can quickly call this over and over.
177
+ // Cached — as long as shortStateName stays the same,
178
+ // repeated calls return instantly.
157
179
  let citizens = store.citizens.getByState(state.shortName);
158
180
  return citizens;
159
181
  }
@@ -163,50 +185,102 @@ const MyStore = createStore({
163
185
 
164
186
  ## Update events
165
187
 
166
- `seqda` emits an `'update'` event when the store has been updated. Unlike Redux, the `'update'` event is only triggered on the *next frame* in the Javascript engine (essentially on `nextTick`). The update event also reports which areas of the store have been updated, unlike Redux. This allows many store updates to happen sequentially, with the update event only being fired once. Let's see an example of this in action.
188
+ `seqda` emits an `'update'` event when the store has been updated. Unlike Redux, the `'update'` event is only triggered on the *next microtask* (via `Promise.resolve().then(...)`). The update event reports which scopes were modified, and provides a frozen read-only snapshot of the previous state. This allows many store updates to happen sequentially, with only one event fired.
167
189
 
168
- *Note: When the scope name of the update event is `'*'`, this means that the entire store has been updated. This can happen for example when the store is hydrated with a `.hydrate` call.*
190
+ *Note: When the scope name in the `modified` array is `'*'`, the entire store has been updated (e.g., via `.hydrate()`).*
169
191
 
170
192
  ```javascript
171
- const { createStore } = require('seqda');
193
+ import { createStore } from 'seqda';
194
+
172
195
  const MyStore = createStore({
173
196
  todos: {
174
197
  _: [],
175
198
  add({ get, set }, todo) {
176
199
  set([ ...get(), todo ]);
177
200
  },
178
- remove({ get, set }, todo) {
179
- set(get().filter((item) => (item !== todo)));
180
- },
181
- get({ get }, todoID) {
182
- if (arguments.length === 1)
183
- return get();
184
-
185
- return get().find((todo) => (todo.id === todoID));
201
+ get({ get }) {
202
+ return get();
186
203
  },
187
204
  },
188
205
  });
189
206
 
190
- MyStore.on('update', ({ store, modified }) => {
191
- // I am called on `nextTick`, frame 2
192
- console.log('modified: ', modified);
207
+ MyStore.on('update', ({ store, previousStore, modified }) => {
208
+ console.log('modified scopes:', modified);
209
+ // modified scopes: [ 'todos' ]
193
210
 
194
- // modified: [ 'todos' ]
211
+ // previousStore is a frozen read-only clone of the state
212
+ // before this batch of updates:
213
+ console.log('before:', previousStore.todos.get());
214
+ console.log('after:', store.todos.get());
195
215
  });
196
216
 
197
- // frame 1
217
+ // Both adds happen in the same synchronous block —
218
+ // only ONE update event fires, listing 'todos' once.
198
219
  MyStore.todos.add({ todo: 'Do something!', id: 1 });
199
220
  MyStore.todos.add({ todo: 'Do another thing!', id: 2 });
221
+ ```
222
+
223
+ ### Sub-scope paths in `modified`
224
+
225
+ When a sub-scope is updated, the `modified` array contains the dot-separated path to that specific sub-scope:
226
+
227
+ ```javascript
228
+ import { createStore } from 'seqda';
229
+
230
+ const store = createStore({
231
+ data: {
232
+ _: [],
233
+ config: {
234
+ _: { theme: 'dark' },
235
+ set({ get, set }, values) {
236
+ set({ ...get(), ...values });
237
+ },
238
+ },
239
+ },
240
+ });
241
+
242
+ store.on('update', ({ modified }) => {
243
+ console.log(modified);
244
+ // [ 'data.config' ] — the specific sub-scope path
245
+ });
200
246
 
201
- //... now onto frame2, where the "update" event is fired
247
+ store.data.config.set({ theme: 'light' });
248
+ ```
249
+
250
+ ### Custom events
251
+
252
+ The seqda store IS a Node.js `EventEmitter`. You can emit your own custom events through it alongside seqda's built-in events. Custom events fire **synchronously** (unlike seqda's batched `update` event):
253
+
254
+ ```javascript
255
+ import { createStore } from 'seqda';
256
+
257
+ const store = createStore({
258
+ items: {
259
+ _: {},
260
+ put({ get, set }, item) {
261
+ set({ ...get(), [item.id]: item });
262
+ },
263
+ },
264
+ });
265
+
266
+ // Subscribe to a custom namespaced event
267
+ store.on('item:added:abc123', (data) => {
268
+ console.log('Item added:', data.item);
269
+ });
270
+
271
+ // Your wrapper can emit custom events synchronously
272
+ // during operations, while seqda handles state batching:
273
+ let item = { id: 'abc123', name: 'test' };
274
+ store.items.put(item);
275
+ store.emit(`item:added:${item.id}`, { item });
202
276
  ```
203
277
 
204
278
  ## Fetch events
205
279
 
206
- `seqda` also reports which scopes are being fetched. Simply listen for the "fetchScope" event to know which areas of the store have been accessed for any given operation.
280
+ `seqda` can report which scopes are being read. Enable with `{ emitOnFetch: true }` and listen for the `'fetchScope'` event:
207
281
 
208
282
  ```javascript
209
- const { createStore } = require('seqda');
283
+ import { createStore } from 'seqda';
210
284
 
211
285
  const MyStore = createStore({
212
286
  todos: {
@@ -214,29 +288,20 @@ const MyStore = createStore({
214
288
  add({ get, set }, todo) {
215
289
  set([ ...get(), todo ]);
216
290
  },
217
- remove({ get, set }, todo) {
218
- set(get().filter((item) => (item !== todo)));
219
- },
220
- get({ get }, todoID) {
221
- if (arguments.length === 1)
222
- return get();
223
-
224
- return get().find((todo) => (todo.id === todoID));
291
+ get({ get }) {
292
+ return get();
225
293
  },
226
294
  },
227
- });
295
+ }, { emitOnFetch: true });
228
296
 
229
297
  MyStore.todos.add({ todo: 'Do something!', id: 1 });
230
- MyStore.todos.add({ todo: 'Do another thing!', id: 2 });
231
298
 
232
299
  MyStore.on('fetchScope', ({ store, scopeName }) => {
233
- console.log('scope fetched: ', scopeName);
300
+ console.log('scope fetched:', scopeName);
234
301
  });
235
302
 
236
303
  MyStore.todos.get();
237
-
238
- // output:
239
- // scoped fetched: todos
304
+ // output: scope fetched: todos
240
305
  ```
241
306
 
242
307
  ## Async methods
@@ -244,7 +309,7 @@ MyStore.todos.get();
244
309
  There is nothing in `seqda` preventing you from using async methods. The store will only update once `set` is called inside a method, and `set` won't be called until your asynchronous code is complete.
245
310
 
246
311
  ```javascript
247
- const { createStore } = require('seqda');
312
+ import { createStore } from 'seqda';
248
313
 
249
314
  const MyStore = createStore({
250
315
  users: {
@@ -263,41 +328,117 @@ const MyStore = createStore({
263
328
  },
264
329
  });
265
330
 
266
- let user = await MyStore.getUser(1);
331
+ let user = await MyStore.users.getUser(1);
267
332
  ```
268
333
 
269
- Keep in mind that methods inside `seqda` are not asynchronous in nature, so the result of the above `getUser` call will cache the returned promise (not the resolved value of that promise). Now this shouldn't be an issue, because if you have an asynchronous method, so you will always be awaiting on the result, so the cached promise--if returned from cache--will provide the same result.
334
+ Keep in mind that methods inside `seqda` are not asynchronous in nature, so the result of the above `getUser` call will cache the returned promise (not the resolved value of that promise). Now this shouldn't be an issue, because if you have an asynchronous method, you will always be awaiting on the result, so the cached promise--if returned from cache--will provide the same result.
270
335
 
271
336
  ```javascript
272
337
  // Caches the promise
273
- let user = await MyStore.getUser(1);
338
+ let user = await MyStore.users.getUser(1);
274
339
 
275
340
  // Returns the cached promise
276
- user = await MyStore.getUser(1);
341
+ user = await MyStore.users.getUser(1);
277
342
 
278
343
  // Result = same
279
344
  ```
280
345
 
281
- ## Seqda is built for speed an simplicity
346
+ ## Performance
347
+
348
+ Unlike Redux, where dispatching an action recalculates the entire store, `seqda` only updates the specific scope (and its parent path) that was modified. Combined with per-method caching and batched update events, this makes `seqda` efficient for high-frequency updates.
349
+
350
+ The `'update'` event fires once per microtask tick after all synchronous writes settle. If you have UI components listening for store updates, they re-render once after the batch — not once per write.
351
+
352
+ ## Cloning stores
353
+
354
+ You can clone a store with `cloneStore()`. Cloned stores are fully independent — mutations in the clone don't affect the original.
355
+
356
+ ```javascript
357
+ import { createStore, cloneStore } from 'seqda';
358
+
359
+ const store = createStore({
360
+ todos: {
361
+ _: [],
362
+ add({ get, set }, todo) {
363
+ set([...get(), todo]);
364
+ },
365
+ get({ get }) {
366
+ return get();
367
+ },
368
+ },
369
+ });
370
+
371
+ store.todos.add({ id: 1, text: 'Original' });
372
+
373
+ // Mutable clone
374
+ let clone = cloneStore(store);
375
+ clone.todos.add({ id: 2, text: 'Clone only' });
376
+
377
+ console.log(store.todos.get().length); // 1
378
+ console.log(clone.todos.get().length); // 2
379
+
380
+ // Read-only clone (set() calls are silently ignored)
381
+ let snapshot = cloneStore(store, true);
382
+ snapshot.todos.add({ id: 3, text: 'Ignored' });
383
+ console.log(snapshot.todos.get().length); // 1
384
+ ```
385
+
386
+ ## Hydrating the store
387
+
388
+ To restore a store from a saved state, use `hydrate()`. This replaces the entire internal state atomically and emits an update with `modified: ['*']`.
282
389
 
283
- Unlike in Redux, where when an action is dispatched, the entire store is recalculated (which can be very heavy for large stores), `seqda` will only update the specific area of the store (and all its parent scopes) that was requested to be updated. This means that for each update operation, only the updated scope (and all parent) scopes are updated, making it much more efficient than Redux.
390
+ ```javascript
391
+ let savedState = JSON.stringify(MyStore.getState());
284
392
 
285
- Also, as already mentioned, the `seqda` `'update'` event is only fired once after all the store updates have settled (on `nextTick` after updates). This is also for efficiency purposes. For example, if you have client components (i.e. React) listening for store updates, then unlike Redux, where the component's state will potentially update dozens of times for a single store dispatch, in `seqda` all your components will only update their state once after the store has settled.
393
+ // Later...
394
+ MyStore.hydrate(JSON.parse(savedState));
395
+ ```
286
396
 
287
- The `seqda` interface was designed to be simple and intuitive. All you really need to understand is scopes, and how to interact with them via `get`, `set`, and fetching other values from the store via `store`. The rest is up to you! Feel free to create complicated caching selectors, or any other useful tools you want. You just need to provide methods to interact with your scopes, and the rest is left to your creative freedom!
397
+ `hydrate()` also invalidates all scope method caches, so any subsequent calls to cached methods will re-read from the new state.
288
398
 
289
399
  ## Middleware
290
400
 
291
401
  Middleware is not currently supported, but I would be happy to add it (or to accept a PR) if anyone needs middleware.
292
402
 
293
- ## Hydrating the store from a stored state
403
+ ## API Reference
294
404
 
295
- To hydrate your store from a stored state, simply call `MyStore.hydrate(storedState)`.
405
+ ### `createStore(template, options?)`
296
406
 
297
- ```javascript
407
+ Creates a new seqda store.
298
408
 
299
- let storedState = JSON.stringify(MyStore.getState());
409
+ - **`template`** Object defining scopes. Each scope has a `_` default value and named methods.
410
+ - **`options.emitOnFetch`** — `boolean` (default: `false`). When `true`, emits `'fetchScope'` events on scope reads.
300
411
 
301
- MyStore.hydrate(JSON.parse(storedState));
412
+ Returns the store instance (an `EventEmitter` with scope methods attached).
302
413
 
303
- ```
414
+ ### Store instance
415
+
416
+ | Method/Property | Description |
417
+ |---|---|
418
+ | `store.getState()` | Returns the current frozen internal state object |
419
+ | `store.hydrate(state)` | Replaces entire state, emits update with `modified: ['*']` |
420
+ | `store.on(event, listener)` | Subscribe to events (inherited from EventEmitter) |
421
+ | `store.off(event, listener)` | Unsubscribe from events |
422
+ | `store.emit(event, data)` | Emit custom events |
423
+
424
+ ### Scope method context
425
+
426
+ Every scope method receives a context object as its first argument:
427
+
428
+ | Property | Description |
429
+ |---|---|
430
+ | `get()` | Read the current state for this scope |
431
+ | `set(value)` | Write a new value for this scope (must be a different reference) |
432
+ | `store` | Reference to the root store — access other scopes |
433
+
434
+ ### Events
435
+
436
+ | Event | Payload | Timing |
437
+ |---|---|---|
438
+ | `'update'` | `{ store, previousStore, modified }` | Async (next microtask), batched |
439
+ | `'fetchScope'` | `{ store, scopeName }` | Sync (immediate), opt-in |
440
+ | Custom events | User-defined | Sync (immediate) |
441
+
442
+ ### `cloneStore(store, readOnly?)`
443
+
444
+ Creates a deep clone of the store. If `readOnly` is `true`, all `set()` calls are silently ignored.