seqda 1.0.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +303 -0
  3. package/package.json +39 -0
  4. package/src/index.js +239 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Wyatt Greenway
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,303 @@
1
+ # `seqda` - Sequential Data Store
2
+
3
+ ## Install
4
+
5
+ NPM:
6
+ ```bash
7
+ npm i --save seqda
8
+ ```
9
+
10
+ Yarn:
11
+ ```bash
12
+ yarn add seqda
13
+ ```
14
+
15
+ ## About
16
+
17
+ `seqda` is a Redux-like global store. Unlike Redux, it doesn't take boiler-plate with the mass of a black-hole to setup, and has a much simpler interface.
18
+
19
+ There are no actions, dispatches, reducers, or selectors per-se. Instead, there are just methods: getters and setters that the user defines. **All** methods are cached, so calling the same method over and over again with the same state and the same argument will simply return the same previous cached result. If you need to invalidate the cache (i.e. on a setter, when you are for some reason continually providing the same input), simply add another randomized argument to invalidate the cache. The cache is always automatically invalidated for all methods in a scope when the state is updated (but only for the scope that had its state updated).
20
+
21
+ ## Creating a data store
22
+
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
+
25
+ ```javascript
26
+ const { createStore } = require('seqda');
27
+ const MyStore = createStore({
28
+ todos: { // This is a "scope"
29
+ _: [], // This is the "default value" for this scope
30
+
31
+ // Then you simply define methods to interact with this data
32
+ add({ get, set }, todo /* ...args, as provided by the user */) {
33
+ // get = fetch the current data from
34
+ // the store for this scope
35
+
36
+ // set = update the data on the store
37
+ // for this scope
38
+
39
+ set([ ...get(), todo ]);
40
+ },
41
+ update({ get, set, store }, todoID, todo) {
42
+ let foundTodo = store.todos.get(todoID);
43
+ if (!foundTodo)
44
+ return;
45
+
46
+ let todos = get();
47
+ if (!todos)
48
+ return;
49
+
50
+ let index = todos.findIndex((todo) => (todo === foundTodo));
51
+ if (index < 0)
52
+ return;
53
+
54
+ todos = todos.slice();
55
+ todos[index] = todo;
56
+
57
+ set(todos);
58
+ },
59
+ remove({ get, set }, todo) {
60
+ set(get().filter((item) => (item !== todo)));
61
+ },
62
+ get({ get }, todoID) {
63
+ if (arguments.length === 1)
64
+ return get();
65
+
66
+ return get().find((todo) => (todo.id === todoID));
67
+ },
68
+ },
69
+ // Define another scope
70
+ config: {
71
+ _: {
72
+ configValue1: null,
73
+ configValue2: null,
74
+ },
75
+ // You can also define sub-scopes
76
+ userConfig: {
77
+ _: {
78
+ firstName: '',
79
+ lastName: '',
80
+ },
81
+
82
+ // Methods go here
83
+ },
84
+
85
+ // Methods go here
86
+ }
87
+ })
88
+
89
+ // We can add a todo by calling our method
90
+ // (notice that the "context") argments ({ get, set })
91
+ // are provided internally by seqda
92
+
93
+ MyStore.todos.add(/* todo */ { todo: 'Do things!', id: 1 });
94
+
95
+ 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
+ }
110
+ ```
111
+
112
+ ## The entire store is frozen
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:
115
+
116
+ ```javascript
117
+ let state = MyStore.getState();
118
+ state.setSomething = toAValue;
119
+
120
+ // throw new TypeError('Cannot add property setSomething, object is not extensible');
121
+ ```
122
+
123
+ ## Method cache
124
+
125
+ 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
+
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.
128
+
129
+ Let's see an example of this in action:
130
+
131
+ ```javascript
132
+ const { createStore } = require('seqda');
133
+
134
+ const MyStore = createStore({
135
+ citizens: {
136
+ _: [],
137
+ getByState({ get }, shortStateName) {
138
+ return get().filter((citizen) => (citizen.state === shortStateName));
139
+ },
140
+ },
141
+ states: {
142
+ _: [],
143
+ get({ get }, stateName) {
144
+ if (!stateName)
145
+ return get(); // if no stateName was provided, then return all states
146
+
147
+ return get().find((state) => (state.name === stateName));
148
+ },
149
+ getCitizensForState({ get, store }, stateName) {
150
+ // First, get the state requested from the store
151
+ 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.
157
+ let citizens = store.citizens.getByState(state.shortName);
158
+ return citizens;
159
+ }
160
+ },
161
+ });
162
+ ```
163
+
164
+ ## Update events
165
+
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.
167
+
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.*
169
+
170
+ ```javascript
171
+ const { createStore } = require('seqda');
172
+ const MyStore = createStore({
173
+ todos: {
174
+ _: [],
175
+ add({ get, set }, todo) {
176
+ set([ ...get(), todo ]);
177
+ },
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));
186
+ },
187
+ },
188
+ });
189
+
190
+ MyStore.on('update', ({ store, modified }) => {
191
+ // I am called on `nextTick`, frame 2
192
+ console.log('modified: ', modified);
193
+
194
+ // modified: [ 'todos' ]
195
+ });
196
+
197
+ // frame 1
198
+ MyStore.todos.add({ todo: 'Do something!', id: 1 });
199
+ MyStore.todos.add({ todo: 'Do another thing!', id: 2 });
200
+
201
+ //... now onto frame2, where the "update" event is fired
202
+ ```
203
+
204
+ ## Fetch events
205
+
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.
207
+
208
+ ```javascript
209
+ const { createStore } = require('seqda');
210
+
211
+ const MyStore = createStore({
212
+ todos: {
213
+ _: [],
214
+ add({ get, set }, todo) {
215
+ set([ ...get(), todo ]);
216
+ },
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));
225
+ },
226
+ },
227
+ });
228
+
229
+ MyStore.todos.add({ todo: 'Do something!', id: 1 });
230
+ MyStore.todos.add({ todo: 'Do another thing!', id: 2 });
231
+
232
+ MyStore.on('fetchScope', ({ store, scopeName }) => {
233
+ console.log('scope fetched: ', scopeName);
234
+ });
235
+
236
+ MyStore.todos.get();
237
+
238
+ // output:
239
+ // scoped fetched: todos
240
+ ```
241
+
242
+ ## Async methods
243
+
244
+ 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
+
246
+ ```javascript
247
+ const { createStore } = require('seqda');
248
+
249
+ const MyStore = createStore({
250
+ users: {
251
+ _: [],
252
+ async getUser({ get, set }, userID) {
253
+ let users = get();
254
+ let user = users[userID];
255
+
256
+ if (!user) {
257
+ user = await API.getUserByID(userID);
258
+ set({ ...users, [user.id]: user });
259
+ }
260
+
261
+ return user;
262
+ }
263
+ },
264
+ });
265
+
266
+ let user = await MyStore.getUser(1);
267
+ ```
268
+
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.
270
+
271
+ ```javascript
272
+ // Caches the promise
273
+ let user = await MyStore.getUser(1);
274
+
275
+ // Returns the cached promise
276
+ user = await MyStore.getUser(1);
277
+
278
+ // Result = same
279
+ ```
280
+
281
+ ## Seqda is built for speed an simplicity
282
+
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.
284
+
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.
286
+
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!
288
+
289
+ ## Middleware
290
+
291
+ Middleware is not currently supported, but I would be happy to add it (or to accept a PR) if anyone needs middleware.
292
+
293
+ ## Hydrating the store from a stored state
294
+
295
+ To hydrate your store from a stored state, simply call `MyStore.hydrate(storedState)`.
296
+
297
+ ```javascript
298
+
299
+ let storedState = JSON.stringify(MyStore.getState());
300
+
301
+ MyStore.hydrate(JSON.parse(storedState));
302
+
303
+ ```
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "seqda",
3
+ "version": "1.0.1",
4
+ "description": "Sequential Data Store",
5
+ "main": "src/index.js",
6
+ "type": "commonjs",
7
+ "scripts": {
8
+ "test": "node ./node_modules/.bin/jasmine",
9
+ "test-debug": "node --inspect-brk ./node_modules/.bin/jasmine",
10
+ "test-watch": "watch 'clear ; node ./node_modules/.bin/jasmine' . --wait=2 --interval=1"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/th317erd/seqda.git"
15
+ },
16
+ "keywords": [
17
+ "redux",
18
+ "store",
19
+ "data",
20
+ "react-state",
21
+ "vuex",
22
+ "state"
23
+ ],
24
+ "author": "Wyatt Greenway",
25
+ "license": "MIT",
26
+ "bugs": {
27
+ "url": "https://github.com/th317erd/seqda/issues"
28
+ },
29
+ "homepage": "https://github.com/th317erd/seqda#readme",
30
+ "devDependencies": {
31
+ "@spothero/eslint-plugin-spothero": "github:spothero/eslint-plugin-spothero",
32
+ "eslint": "^8.18.0",
33
+ "jasmine": "^4.2.1"
34
+ },
35
+ "dependencies": {
36
+ "events": "^3.3.0",
37
+ "nife": "^1.8.1"
38
+ }
39
+ }
package/src/index.js ADDED
@@ -0,0 +1,239 @@
1
+ 'use strict';
2
+
3
+ const Nife = require('nife');
4
+ const EventEmitter = require('events');
5
+
6
+ const QUEUE_CHANGE_EVENT = Symbol.for('__seqdaQueueChangeEvent');
7
+ const QUEUE_CHANGE_INFO = Symbol.for('__seqdaQueueChangeInfo');
8
+ const INTERNAL_STATE = Symbol.for('__seqdaInternalState');
9
+
10
+ function queueChangeEvent(path) {
11
+ let info = this[QUEUE_CHANGE_INFO];
12
+ if (!info.promise) {
13
+ info.promise = Promise.resolve();
14
+ info.promise.then(() => {
15
+ this.emit('update', { store: this, modified: Array.from(Object.keys(info.eventQueue)) });
16
+ info.eventQueue = {};
17
+ info.promise = null;
18
+ });
19
+ }
20
+
21
+ if (!info.eventQueue)
22
+ info.eventQueue = {};
23
+
24
+ info.eventQueue[path] = true;
25
+ }
26
+
27
+ function copyKeysToArray(_value, source) {
28
+ let value = _value;
29
+
30
+ if (Array.isArray(value) && source) {
31
+ let keys = Object.keys(source);
32
+ for (let i = 0, il = keys.length; i < il; i++) {
33
+ let key = keys[i];
34
+ if ((/^\d+$/).test(key))
35
+ continue;
36
+
37
+ value[key] = source[key];
38
+ }
39
+ }
40
+
41
+ return value;
42
+ }
43
+
44
+ function clone(value) {
45
+ if (!value)
46
+ return value;
47
+
48
+ if (value && typeof value === 'object') {
49
+ if (Array.isArray(value))
50
+ return copyKeysToArray(value.slice(), value);
51
+
52
+ return Object.assign({}, value);
53
+ }
54
+
55
+ return value;
56
+ }
57
+
58
+ function setPath(_context, path, value) {
59
+ let context = clone(_context);
60
+ let pathParts = path.split('.');
61
+ let current = context;
62
+
63
+ for (let i = 0, il = pathParts.length; i < il; i++) {
64
+ let pathPart = pathParts[i];
65
+
66
+ if ((i + 1) >= il) {
67
+ let finalValue;
68
+
69
+ if (Array.isArray(value))
70
+ finalValue = copyKeysToArray(value, current[pathPart]);
71
+ else
72
+ finalValue = value;
73
+
74
+ if (finalValue && typeof finalValue === 'object')
75
+ Object.freeze(finalValue);
76
+
77
+ current[pathPart] = finalValue;
78
+ } else {
79
+ current[pathPart] = clone(current[pathPart]);
80
+ }
81
+
82
+ if (current && typeof current === 'object')
83
+ Object.freeze(current);
84
+
85
+ current = current[pathPart];
86
+ }
87
+
88
+ return context;
89
+ }
90
+
91
+ function getPath(...parts) {
92
+ return parts.filter(Boolean).join('.');
93
+ }
94
+
95
+ function createStoreSubsection(store, sectionTemplate, path) {
96
+ const isCacheInvalid = (scopeName, args) => {
97
+ let thisCache = cache[scopeName];
98
+ if (!thisCache)
99
+ return true;
100
+
101
+ let cacheArgs = thisCache.args;
102
+ if (cacheArgs.length !== args.length)
103
+ return true;
104
+
105
+ for (let i = 0, il = args.length; i < il; i++) {
106
+ if (cacheArgs[i] !== args[i])
107
+ return true;
108
+ }
109
+
110
+ return false;
111
+ };
112
+
113
+ const setCache = (scopeName, args, result) => {
114
+ cache[scopeName] = {
115
+ args,
116
+ result,
117
+ };
118
+ };
119
+
120
+ const createScopeMethod = (scopeName, func) => {
121
+ return (...args) => {
122
+ if (isCacheInvalid(scopeName, args)) {
123
+ let result = func({ get, set, store }, ...args);
124
+ setCache(scopeName, args, result);
125
+ return result;
126
+ } else {
127
+ return cache[scopeName].result;
128
+ }
129
+ };
130
+ };
131
+
132
+ const get = () => {
133
+ store.emit('fetchScope', { store, scopeName: path });
134
+ let currentState = Nife.get(store[INTERNAL_STATE], path);
135
+ return currentState;
136
+ };
137
+
138
+ const set = (value) => {
139
+ let currentState = Nife.get(store[INTERNAL_STATE], path);
140
+ if (value && typeof value === 'object' && value === currentState)
141
+ throw new Error(`Error: "${getPath(path)}" the state value is the same, but it is required to be different.`);
142
+
143
+ let previousState = currentState;
144
+ store[INTERNAL_STATE] = setPath(store[INTERNAL_STATE], path, value);
145
+
146
+ cache = {};
147
+
148
+ if (store[QUEUE_CHANGE_EVENT])
149
+ store[QUEUE_CHANGE_EVENT](path, value, previousState);
150
+
151
+ return value;
152
+ };
153
+
154
+ if (path && !Object.prototype.hasOwnProperty.call(sectionTemplate, '_'))
155
+ throw new Error(`Error: "${getPath}._" default value must be defined.`);
156
+
157
+ const scope = (!path) ? store : {};
158
+ let keys = Object.keys(sectionTemplate || {});
159
+ let subScopes = [];
160
+ let cache = {};
161
+
162
+ if (path)
163
+ set(clone(sectionTemplate._));
164
+
165
+ for (let i = 0, il = keys.length; i < il; i++) {
166
+ let key = keys[i];
167
+ if (key === '_')
168
+ continue;
169
+
170
+ let value = sectionTemplate[key];
171
+ if (Nife.instanceOf(value, 'object')) {
172
+ scope[key] = createStoreSubsection(store, value, getPath(path, key));
173
+ subScopes.push(key);
174
+ continue;
175
+ }
176
+
177
+ if (typeof value !== 'function')
178
+ throw new TypeError(`Error: Value of "${getPath(path, key)}" is invalid. All properties must be functions, or sub scopes.`);
179
+
180
+ scope[key] = createScopeMethod(key, value);
181
+ }
182
+
183
+ if (!path)
184
+ return scope; // We can't freeze the store
185
+ else
186
+ return Object.freeze(scope);
187
+ }
188
+
189
+ function createStore(template) {
190
+ if (!Nife.instanceOf(template, 'object'))
191
+ throw new TypeError('createStore: provided "template" must be an object.');
192
+
193
+ const store = new EventEmitter();
194
+
195
+ Object.defineProperty(store, INTERNAL_STATE, {
196
+ writable: true,
197
+ enumerable: false,
198
+ configurable: true,
199
+ value: {},
200
+ });
201
+
202
+ let constructedStore = createStoreSubsection(store, template);
203
+
204
+ Object.defineProperties(constructedStore, {
205
+ 'getState': {
206
+ writable: false,
207
+ enumberable: false,
208
+ configurable: false,
209
+ value: () => constructedStore[INTERNAL_STATE],
210
+ },
211
+ 'hydrate': {
212
+ writable: false,
213
+ enumberable: false,
214
+ configurable: false,
215
+ value: (value) => {
216
+ constructedStore[INTERNAL_STATE] = Object.freeze(clone(value));
217
+ queueChangeEvent.call(constructedStore, '*');
218
+ },
219
+ },
220
+ [QUEUE_CHANGE_EVENT]: {
221
+ writable: false,
222
+ enumberable: false,
223
+ configurable: false,
224
+ value: queueChangeEvent.bind(constructedStore),
225
+ },
226
+ [QUEUE_CHANGE_INFO]: {
227
+ writable: true,
228
+ enumberable: false,
229
+ configurable: false,
230
+ value: {},
231
+ },
232
+ });
233
+
234
+ return constructedStore;
235
+ }
236
+
237
+ module.exports = {
238
+ createStore,
239
+ };