├── CHANGELOG.md ├── .eslintignore ├── .prettierrc.js ├── .gitignore ├── src ├── utils │ ├── cloneBlob.ts │ ├── cloneArray.ts │ ├── cloneFile.ts │ ├── computeTag.ts │ ├── isBlob.ts │ ├── isFile.ts │ ├── isContainer.ts │ ├── isPrimitive.ts │ ├── isStoreRecord.ts │ ├── warning.ts │ ├── deepFreeze.ts │ ├── getContainer.ts │ ├── cloneComplex.ts │ ├── getByPath.ts │ ├── createPathFactory.ts │ ├── computePathLineage.ts │ ├── createReadManager.ts │ ├── eventTypeGuards.ts │ └── clone.ts ├── index.ts ├── createStore.ts └── types │ └── index.ts ├── .github ├── PULL_REQUEST_TEMPLATE.md └── ISSUE_TEMPLATE.md ├── __tests__ ├── utils │ ├── isBlob.spec.ts │ ├── isFile.spec.ts │ ├── isStoreRecord.spec.ts │ ├── getContainer.spec.ts │ ├── isContainer.spec.ts │ ├── getByPath.spec.ts │ ├── isPrimitive.spec.ts │ ├── deepFreeze.spec.ts │ ├── createPathFactory.spec.ts │ ├── computePathLineage.spec.ts │ ├── createReadManager.spec.ts │ ├── eventTypeGuards.spec.ts │ └── clone.spec.ts └── createStore.spec.ts ├── CONTRIBUTING.md ├── .circleci └── config.yml ├── rollup.config.js ├── tsconfig.json ├── .eslintrc.js ├── LICENSE ├── package.json ├── CODE-OF-CONDUCT.md ├── README.md └── docs ├── getting-started-react-gact-store-tutorial.md ├── death-of-component-state.md ├── decoupled-state-interface.md └── white-paper.md /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | printWidth: 80 4 | }; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .DS_Store 4 | .coveralls.yml 5 | *.log 6 | .vscode 7 | dist 8 | 9 | -------------------------------------------------------------------------------- /src/utils/cloneBlob.ts: -------------------------------------------------------------------------------- 1 | export function cloneBlob(blob: Blob): Blob { 2 | return new Blob([blob.slice()], { type: blob.type }); 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/cloneArray.ts: -------------------------------------------------------------------------------- 1 | import { StoreArray } from "../types"; 2 | 3 | export function cloneArray(array: StoreArray): StoreArray { 4 | return new Array(array.length); 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/cloneFile.ts: -------------------------------------------------------------------------------- 1 | export function cloneFile(file: File): File { 2 | return new File([file.slice()], file.name, { 3 | lastModified: file.lastModified, 4 | type: file.type 5 | }); 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/computeTag.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Computes a tag that can be used to determine the type of a value 3 | */ 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | export function computeTag(value: any): string { 6 | return Object.prototype.toString.call(value); 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/isBlob.ts: -------------------------------------------------------------------------------- 1 | import { computeTag } from "./computeTag"; 2 | 3 | /** 4 | * Type guard for Blob 5 | */ 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | export function isBlob(value: any): value is Blob { 8 | return computeTag(value) === "[object Blob]"; 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/isFile.ts: -------------------------------------------------------------------------------- 1 | import { computeTag } from "./computeTag"; 2 | 3 | /** 4 | * A type guard for `File`. 5 | */ 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | export function isFile(value: any): value is File { 8 | return computeTag(value) === "[object File]"; 9 | } 10 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Before submitting a pull request,** please make sure the following is done: 2 | 3 | 1. Fork [the repository](https://github.com/gactjs/store) and create your branch from `master`. 4 | 2. Run `yarn` in the repository root. 5 | 3. If you've fixed a bug or added code that should be tested, add tests! 6 | -------------------------------------------------------------------------------- /src/utils/isContainer.ts: -------------------------------------------------------------------------------- 1 | import { Container } from "../types"; 2 | import { isStoreRecord } from "./isStoreRecord"; 3 | 4 | /** 5 | * A type guard for Container 6 | */ 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | export function isContainer(value: any): value is Container { 9 | return Array.isArray(value) || isStoreRecord(value); 10 | } 11 | -------------------------------------------------------------------------------- /__tests__/utils/isBlob.spec.ts: -------------------------------------------------------------------------------- 1 | import { isBlob } from "../../src/utils/isBlob"; 2 | 3 | describe("isBlob", function() { 4 | test("Blob is a Blob", function() { 5 | expect(isBlob(new Blob())).toBe(true); 6 | }); 7 | 8 | test("Only Blob is a Blob", function() { 9 | expect(isBlob(100)).toBe(false); 10 | expect(isBlob([])).toBe(false); 11 | expect(isBlob({})).toBe(false); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to store 2 | 3 | Thank you for considering contribution to store. 4 | 5 | ## Sending a Pull Request 6 | 7 | **Before submitting a pull request,** please make sure the following is done: 8 | 9 | 1. Fork [the repository](https://github.com/gactjs/store) and create your branch from `master`. 10 | 2. If you've fixed a bug or added code that should be tested, add tests! 11 | 3. If you've changed APIs, update the documentation. 12 | -------------------------------------------------------------------------------- /__tests__/utils/isFile.spec.ts: -------------------------------------------------------------------------------- 1 | import { isFile } from "../../src/utils/isFile"; 2 | 3 | describe("isFile", function() { 4 | test("File is a File", function() { 5 | const file = new File([new Blob()], "example"); 6 | expect(isFile(file)).toBe(true); 7 | }); 8 | 9 | test("Only File is a File", function() { 10 | expect(isFile(100)).toBe(false); 11 | expect(isFile([])).toBe(false); 12 | expect(isFile({})).toBe(false); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/utils/isPrimitive.ts: -------------------------------------------------------------------------------- 1 | import { Primitive } from "../types"; 2 | 3 | /** 4 | * A type guard for `Primitive`. 5 | */ 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | export function isPrimitive(value: any): value is Primitive { 8 | const type = typeof value; 9 | 10 | return ( 11 | type === "string" || 12 | type === "number" || 13 | type === "bigint" || 14 | type === "boolean" || 15 | value === null 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/isStoreRecord.ts: -------------------------------------------------------------------------------- 1 | import { StoreRecord } from "../types"; 2 | 3 | /** 4 | * A type guard for `StoreRecord`. 5 | */ 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | export function isStoreRecord(value: any): value is StoreRecord { 8 | if (typeof value !== "object" || value === null) { 9 | return false; 10 | } 11 | 12 | const proto = Object.getPrototypeOf(value); 13 | 14 | return proto === null || proto === Object.prototype; 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/warning.ts: -------------------------------------------------------------------------------- 1 | export function warning(message: string): void { 2 | if (typeof console !== "undefined" && typeof console.error === "function") { 3 | // tslint:disable-next-line no-console 4 | console.error(message); 5 | } 6 | 7 | try { 8 | // This error was thrown as a convenience so that if you enable 9 | // "break on all exceptions" in your console, 10 | // it would pause the execution at this line. 11 | throw new Error(message); 12 | // eslint-disable-next-line no-empty 13 | } catch (e) {} 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/deepFreeze.ts: -------------------------------------------------------------------------------- 1 | import { isContainer } from "./isContainer"; 2 | import { isPrimitive } from "./isPrimitive"; 3 | 4 | /** 5 | * Deep freezes values 6 | * 7 | * @remarks 8 | * value is guaranteed to conform to the `StoreValue`s invariants 9 | */ 10 | export function deepFreeze(value: T): T { 11 | if (isPrimitive(value)) { 12 | return value; 13 | } 14 | 15 | if (isContainer(value)) { 16 | Object.values(value).forEach(deepFreeze); 17 | } 18 | 19 | return Object.isFrozen(value) ? value : Object.freeze(value); 20 | } 21 | -------------------------------------------------------------------------------- /__tests__/utils/isStoreRecord.spec.ts: -------------------------------------------------------------------------------- 1 | import { isStoreRecord } from "../../src/utils/isStoreRecord"; 2 | 3 | describe("isStoreRecord", function() { 4 | test("Plain objects are records ", function() { 5 | expect(isStoreRecord({})).toBe(true); 6 | expect(isStoreRecord(Object.create(null))).toBe(true); 7 | expect(isStoreRecord({ one: 1, two: 2 })).toBe(true); 8 | }); 9 | 10 | test("only plain objects are records", function() { 11 | expect(isStoreRecord(100)).toBe(false); 12 | expect(isStoreRecord([])).toBe(false); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /__tests__/utils/getContainer.spec.ts: -------------------------------------------------------------------------------- 1 | import { getContainer } from "../../src/utils/getContainer"; 2 | 3 | describe("getContainer", function() { 4 | test("gets container", function() { 5 | const state = { 6 | a: [10, 20, 30] 7 | }; 8 | expect(getContainer(state, ["a", 0])).toStrictEqual([10, 20, 30]); 9 | }); 10 | 11 | test("throws if you try to get container of the root", function() { 12 | expect(function() { 13 | const state = 100; 14 | getContainer(state, []); 15 | }).toThrowError("does not have a container"); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:12.12.0 6 | steps: 7 | - checkout 8 | - run: 9 | name: install 10 | command: yarn install 11 | - run: 12 | name: lint 13 | command: yarn lint 14 | - run: 15 | name: test 16 | command: yarn test 17 | - run: 18 | name: report-coverage 19 | command: | 20 | if [ "${CIRCLE_BRANCH}" == "master" ]; then 21 | yarn report-coverage 22 | fi 23 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import sourceMaps from "rollup-plugin-sourcemaps"; 2 | import typescript from "rollup-plugin-typescript2"; 3 | import pkg from "./package.json"; 4 | 5 | // eslint-disable-next-line no-restricted-syntax 6 | export default { 7 | external: [], 8 | input: `src/index.ts`, 9 | output: [ 10 | { file: pkg.main, format: "umd", name: "store", sourcemap: true }, 11 | { file: pkg.module, format: "es", sourcemap: true } 12 | ], 13 | plugins: [typescript({ useTsconfigDeclarationDir: true }), sourceMaps()], 14 | watch: { 15 | include: "src/**" 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { createStore } from "./createStore"; 2 | export { 3 | Complex, 4 | Container, 5 | ContainerKey, 6 | CRUDEvent, 7 | EventType, 8 | GetEvent, 9 | InitEvent, 10 | Listener, 11 | Path, 12 | PathFactory, 13 | PathFor, 14 | Primitive, 15 | RemoveEvent, 16 | SetEvent, 17 | Store, 18 | StoreArray, 19 | StoreEvent, 20 | StoreRecord, 21 | StoreValue, 22 | TransactionEvent, 23 | UpdateEvent, 24 | Updater, 25 | WriteEvent 26 | } from "./types"; 27 | export { computePathLineage } from "./utils/computePathLineage"; 28 | export * from "./utils/eventTypeGuards"; 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | "lib": ["dom"], 7 | "strict": true, 8 | "sourceMap": true, 9 | "declaration": true, 10 | "keyofStringsOnly": true, // Used to circumvent: "Type instantiation is excessively deep and possibly infinite". 11 | "downlevelIteration": true, 12 | "esModuleInterop": true, 13 | "declarationDir": "dist/types", 14 | "outDir": "dist/lib", 15 | "resolveJsonModule": true, 16 | "typeRoots": ["node_modules/@types"] 17 | }, 18 | "include": ["src"] 19 | } 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Do you want to request a feature or report a bug? 2 | 3 | What is the current behavior? 4 | 5 | If the current behavior is a bug, please provide the steps to reproduce and if possible a minimal demo of the problem. Your bug will get fixed much faster if we can run your code and it doesn't have dependencies other than the Gact store. Paste the link to your JSFiddle (https://jsfiddle.net/Luktwrdm/) or CodeSandbox (https://codesandbox.io/s/new) example below: 6 | 7 | What is the expected behavior? 8 | 9 | Which versions of store, and which browser / OS are affected by this issue? Did this work in previous versions of key? 10 | -------------------------------------------------------------------------------- /__tests__/utils/isContainer.spec.ts: -------------------------------------------------------------------------------- 1 | import { isContainer } from "../../src/utils/isContainer"; 2 | 3 | describe("isContainer", function() { 4 | test("plain objects are containers", function() { 5 | expect(isContainer({})).toBe(true); 6 | expect(isContainer(Object.create(null))).toBe(true); 7 | expect(isContainer({ one: 1, two: 2 })).toBe(true); 8 | }); 9 | 10 | test("arrays are containers", function() { 11 | expect(isContainer([])).toBe(true); 12 | expect(isContainer([1, 2])).toBe(true); 13 | }); 14 | 15 | test("only plain objects and arrays are containers", function() { 16 | expect(isContainer(100)).toBe(false); 17 | expect(isContainer(new Blob())).toBe(false); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /__tests__/utils/getByPath.spec.ts: -------------------------------------------------------------------------------- 1 | import { getByPath } from "../../src/utils/getByPath"; 2 | 3 | describe("getByPath", function() { 4 | test("get root", function() { 5 | const state = 100; 6 | expect(getByPath(state, [])).toBe(state); 7 | }); 8 | 9 | test("get a deep value", function() { 10 | const state = { 11 | a: { 12 | b: { 13 | c: [100] 14 | } 15 | } 16 | }; 17 | 18 | expect(getByPath(state, ["a", "b", "c", 0])).toBe(100); 19 | }); 20 | 21 | test("getting a nonexistent value returns throws", function() { 22 | const state = [100]; 23 | expect(function() { 24 | getByPath(state, [1]); 25 | }).toThrowError("does not exist"); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/utils/getContainer.ts: -------------------------------------------------------------------------------- 1 | import { getByPath } from "./getByPath"; 2 | import { StoreValue, Container, Path, PathFor } from "../types"; 3 | 4 | /** 5 | * Gets parent of the value at a given path. 6 | * 7 | * @typeParam S - the state tree 8 | * @typeParam P - the path 9 | * @typeParam V - the value in S at P 10 | * 11 | */ 12 | export function getContainer< 13 | S extends StoreValue, 14 | P extends Path, 15 | V extends StoreValue 16 | >(state: S, path: P | PathFor): Container { 17 | if (path.length === 0) { 18 | throw Error("the state tree does not have a container"); 19 | } 20 | 21 | const containerPath = path.slice(0, -1) as PathFor; 22 | return getByPath(state, containerPath); 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/cloneComplex.ts: -------------------------------------------------------------------------------- 1 | import { Complex } from "../types"; 2 | import { cloneArray } from "./cloneArray"; 3 | import { cloneBlob } from "./cloneBlob"; 4 | import { cloneFile } from "./cloneFile"; 5 | import { isBlob } from "./isBlob"; 6 | import { isFile } from "./isFile"; 7 | import { isStoreRecord } from "./isStoreRecord"; 8 | 9 | export function cloneComplex(value: T): T { 10 | let result; 11 | if (isStoreRecord(value)) { 12 | result = {}; 13 | } else if (Array.isArray(value)) { 14 | result = cloneArray(value); 15 | } else if (isBlob(value)) { 16 | result = cloneBlob(value); 17 | } else if (isFile(value)) { 18 | result = cloneFile(value); 19 | } else { 20 | throw new Error(`${value} is not cloneable.`); 21 | } 22 | 23 | return result as T; 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/getByPath.ts: -------------------------------------------------------------------------------- 1 | import { PathFor, StoreValue } from "../types"; 2 | import { isContainer } from "./isContainer"; 3 | 4 | /** 5 | * Gets the value in the provided state at the provided path. 6 | * 7 | * @remarks 8 | * Throws if the path does not exist 9 | * 10 | * @typeParam S - the state tree 11 | * @typeParam P - the path 12 | * @typeParam V - the value in S at P 13 | */ 14 | export function getByPath( 15 | state: S, 16 | path: PathFor 17 | ): V { 18 | let value: StoreValue = state; 19 | for (const pathPart of path) { 20 | if ( 21 | !( 22 | isContainer(value) && 23 | Object.prototype.hasOwnProperty.call(value, pathPart) 24 | ) 25 | ) { 26 | throw Error(`${path} does not exist`); 27 | } 28 | 29 | value = Reflect.get(value, pathPart); 30 | } 31 | 32 | return value as V; 33 | } 34 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | plugins: ["@typescript-eslint", "eslint-plugin-tsdoc", "sort-keys-fix"], 5 | extends: [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | rules: { 11 | "func-style": ["error", "declaration"], 12 | "no-restricted-syntax": [ 13 | "error", 14 | "ClassDeclaration", 15 | "ExportDefaultDeclaration" 16 | ], 17 | "sort-keys": [ 18 | "error", 19 | "asc", 20 | { caseSensitive: true, natural: false, minKeys: 2 } 21 | ], 22 | "sort-keys-fix/sort-keys-fix": "error", 23 | "@typescript-eslint/no-explicit-any": ["error", { ignoreRestArgs: true }], 24 | "@typescript-eslint/no-non-null-assertion": "off", 25 | "tsdoc/syntax": "warn", 26 | "react/prop-types": "off" 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /__tests__/utils/isPrimitive.spec.ts: -------------------------------------------------------------------------------- 1 | import { isPrimitive } from "../../src/utils/isPrimitive"; 2 | 3 | describe("isPrimitive", function() { 4 | test("string is a Primitive", function() { 5 | expect(isPrimitive("example")).toBe(true); 6 | }); 7 | 8 | test("number is a Primitive", function() { 9 | expect(isPrimitive(100)).toBe(true); 10 | }); 11 | 12 | test("bigint is a Primitive", function() { 13 | expect(isPrimitive(BigInt(100))).toBe(true); 14 | }); 15 | 16 | test("boolean is a primitive", function() { 17 | expect(isPrimitive(true)).toBe(true); 18 | }); 19 | 20 | test("boolean is a primitive", function() { 21 | expect(isPrimitive(null)).toBe(true); 22 | }); 23 | 24 | test("Only string, number, bigint, boolean, and null are primitives", function() { 25 | expect(isPrimitive(Symbol())).toBe(false); 26 | expect(isPrimitive([])).toBe(false); 27 | expect(isPrimitive({})).toBe(false); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Mateusz Okon 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. -------------------------------------------------------------------------------- /__tests__/utils/deepFreeze.spec.ts: -------------------------------------------------------------------------------- 1 | import { deepFreeze } from "../../src/utils/deepFreeze"; 2 | 3 | describe("deepFreeze", function() { 4 | test("array", function() { 5 | const arr = [0]; 6 | deepFreeze(arr); 7 | expect(function() { 8 | arr[0] = 1; 9 | }).toThrow(); 10 | }); 11 | 12 | test("deep array", function() { 13 | const deepArr = [[[0]], [[0]], [[0]]]; 14 | deepFreeze(deepArr); 15 | 16 | expect(function() { 17 | deepArr[0] = [[1]]; 18 | }).toThrow(); 19 | 20 | expect(function() { 21 | deepArr[0][0] = [1]; 22 | }).toThrow(); 23 | 24 | expect(function() { 25 | deepArr[0][0][0] = 1; 26 | }).toThrow(); 27 | }); 28 | 29 | test("record", function() { 30 | const record = { a: 0 }; 31 | deepFreeze(record); 32 | 33 | expect(function() { 34 | record.a = 1; 35 | }).toThrow(); 36 | }); 37 | 38 | test("deep record", function() { 39 | const record = { a: { b: { c: 0 } } }; 40 | deepFreeze(record); 41 | 42 | expect(function() { 43 | record.a = { b: { c: 1 } }; 44 | }).toThrow(); 45 | 46 | expect(function() { 47 | record.a.b = { c: 1 }; 48 | }).toThrow(); 49 | 50 | expect(function() { 51 | record.a.b.c = 1; 52 | }).toThrow(); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/utils/createPathFactory.ts: -------------------------------------------------------------------------------- 1 | import { Path, PathFactory, PathFactoryResult, StoreValue } from "../types"; 2 | 3 | /** 4 | * Creates a path factory 5 | * 6 | * @remarks 7 | * - paths are canonicalized (i.e the same reference is returned for the same path) 8 | * - paths are frozen (i.e immutable) 9 | * - a `pathFactory` makes it easy to create paths of the correct tuple type 10 | * - a `pathFactory` also enables easy path composition 11 | * 12 | * @typeParam S - the state tree 13 | */ 14 | export function createPathFactory(): PathFactoryResult< 15 | S 16 | > { 17 | const canonicalPaths: Map = new Map(); 18 | 19 | function fromFactory(path: Path): boolean { 20 | return canonicalPaths.get(String(path)) === path; 21 | } 22 | 23 | function path( 24 | ...pathParts: Array> 25 | ): readonly string[] { 26 | const path: Array = []; 27 | for (const pathPart of pathParts) { 28 | if (Array.isArray(pathPart)) { 29 | path.push(...pathPart); 30 | } else { 31 | path.push(pathPart); 32 | } 33 | } 34 | 35 | const pathKey = String(path); 36 | if (canonicalPaths.has(pathKey)) { 37 | return canonicalPaths.get(pathKey)!; 38 | } else { 39 | canonicalPaths.set(pathKey, Object.freeze(path)); 40 | return path; 41 | } 42 | } 43 | 44 | return { fromFactory, path: path as PathFactory }; 45 | } 46 | -------------------------------------------------------------------------------- /src/utils/computePathLineage.ts: -------------------------------------------------------------------------------- 1 | import { Path, PathFor, StoreValue } from "../types"; 2 | import { isContainer } from "./isContainer"; 3 | 4 | function computeAncestorPaths( 5 | path: PathFor 6 | ): Set> { 7 | const ancestorPaths: Set> = new Set(); 8 | for (let i = 0; i < path.length; i++) { 9 | ancestorPaths.add(path.slice(0, i) as Path); 10 | } 11 | return ancestorPaths; 12 | } 13 | 14 | function computeDescendantPaths( 15 | path: PathFor, 16 | value: V | null 17 | ): Set> { 18 | // only containers have descendant paths 19 | if (!isContainer(value)) { 20 | return new Set(); 21 | } 22 | 23 | const descendantPaths: Set> = new Set(); 24 | 25 | for (const [key, childValue] of Object.entries(value)) { 26 | const childPath = [...path, key] as Path; 27 | descendantPaths.add(childPath); 28 | for (const descendantPath of computeDescendantPaths( 29 | childPath, 30 | childValue 31 | )) { 32 | descendantPaths.add(descendantPath); 33 | } 34 | } 35 | 36 | return descendantPaths; 37 | } 38 | 39 | /** 40 | * Computes the set of paths containing the path itself, ancestors, and descendants. 41 | * 42 | * @typeParam S - the state tree 43 | * @typeParam P - the path 44 | * @typeParam V - the value in S at P 45 | */ 46 | export function computePathLineage( 47 | path: PathFor, 48 | value: V | null 49 | ): Set> { 50 | return new Set([ 51 | ...computeAncestorPaths(path), 52 | (path as unknown) as Path, 53 | ...computeDescendantPaths(path, value) 54 | ]); 55 | } 56 | -------------------------------------------------------------------------------- /__tests__/utils/createPathFactory.spec.ts: -------------------------------------------------------------------------------- 1 | import { createPathFactory } from "../../src/utils/createPathFactory"; 2 | 3 | describe("createPathFactory", function() { 4 | type State = { 5 | a: number; 6 | b: Array; 7 | c: { 8 | d: { 9 | e: boolean; 10 | }; 11 | }; 12 | }; 13 | 14 | test("creates paths from keys", function() { 15 | const { path } = createPathFactory(); 16 | expect(path("a")).toStrictEqual(["a"]); 17 | expect(path("b", 0)).toStrictEqual(["b", 0]); 18 | expect(path("c", "d", "e")).toStrictEqual(["c", "d", "e"]); 19 | }); 20 | 21 | test("path composition", function() { 22 | const { path } = createPathFactory(); 23 | const b = path("b"); 24 | expect(path(b, 0)).toStrictEqual(["b", 0]); 25 | 26 | const c = path("c"); 27 | const cd = path(c, "d"); 28 | expect(cd).toStrictEqual(["c", "d"]); 29 | expect(path(cd, "e")).toStrictEqual(["c", "d", "e"]); 30 | }); 31 | 32 | test("path canonicalization", function() { 33 | const { path } = createPathFactory(); 34 | const cde1 = path("c", "d", "e"); 35 | const cde2 = path(path("c"), "d", "e"); 36 | const cde3 = path(path("c", "d"), "e"); 37 | expect(cde1).toBe(cde2); 38 | expect(cde2).toBe(cde3); 39 | }); 40 | 41 | test("path immutability", function() { 42 | const { path } = createPathFactory(); 43 | const b = path("b"); 44 | expect(function() { 45 | b[0] = "b"; 46 | }).toThrowError(); 47 | }); 48 | 49 | test("fromFactory", function() { 50 | const { path, fromFactory } = createPathFactory(); 51 | const c = path("c"); 52 | expect(fromFactory(c)).toBe(true); 53 | expect(fromFactory(["c"])).toBe(false); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/utils/createReadManager.ts: -------------------------------------------------------------------------------- 1 | import { Complex, Path, PathFor, ReadManager, StoreValue } from "../types"; 2 | import { cloneComplex } from "./cloneComplex"; 3 | import { isContainer } from "./isContainer"; 4 | import { isPrimitive } from "./isPrimitive"; 5 | 6 | /** 7 | * Creates a `ReadManager` 8 | */ 9 | export function createReadManager(): ReadManager { 10 | const frozenClones: Map = new Map(); 11 | 12 | /** 13 | * structurally clones the provided value 14 | * 15 | * @remarks 16 | * value is guaranteed to conform to the `StoreValue` invariants since we only 17 | * structurally clone values that are already in the store 18 | */ 19 | function clone(path: PathFor, value: V): V { 20 | if (isPrimitive(value)) { 21 | return value; 22 | } 23 | 24 | if (frozenClones.has(String(path))) { 25 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 26 | return frozenClones.get(String(path))! as V; 27 | } 28 | 29 | const result = cloneComplex(value as Complex); 30 | 31 | if (isContainer(value)) { 32 | Object.entries(value).forEach(function([key, value]) { 33 | Reflect.set(result, key, clone([...path, key] as Path, value)); 34 | }); 35 | } 36 | 37 | Object.freeze(result); 38 | frozenClones.set(String(path), result); 39 | 40 | return result as V; 41 | } 42 | 43 | /** 44 | * clears the map of stored frozen clones 45 | */ 46 | function reset(): void { 47 | frozenClones.clear(); 48 | } 49 | 50 | /** 51 | * removes stale (specified by a set of paths) frozen clones 52 | */ 53 | function reconcile(paths: Set>): void { 54 | for (const path of paths) { 55 | frozenClones.delete(String(path)); 56 | } 57 | } 58 | 59 | return { 60 | clone, 61 | reconcile, 62 | reset 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /src/utils/eventTypeGuards.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CRUDEvent, 3 | EventType, 4 | GetEvent, 5 | InitEvent, 6 | RemoveEvent, 7 | SetEvent, 8 | StoreEvent, 9 | StoreValue, 10 | TransactionEvent, 11 | UpdateEvent, 12 | WriteEvent 13 | } from "../types"; 14 | 15 | export function isInitEvent( 16 | event: StoreEvent 17 | ): event is InitEvent { 18 | return event.type === EventType.Init; 19 | } 20 | 21 | export function isGetEvent( 22 | event: StoreEvent 23 | ): event is GetEvent { 24 | return event.type === EventType.Get; 25 | } 26 | 27 | export function isSetEvent( 28 | event: StoreEvent 29 | ): event is SetEvent { 30 | return event.type === EventType.Set; 31 | } 32 | 33 | export function isUpdateEvent( 34 | event: StoreEvent 35 | ): event is UpdateEvent { 36 | return event.type === EventType.Update; 37 | } 38 | 39 | export function isRemoveEvent( 40 | event: StoreEvent 41 | ): event is RemoveEvent { 42 | return event.type === EventType.Remove; 43 | } 44 | 45 | export function isWriteEvent( 46 | event: StoreEvent 47 | ): event is WriteEvent { 48 | return ( 49 | event.type === EventType.Set || 50 | event.type === EventType.Update || 51 | event.type === EventType.Remove 52 | ); 53 | } 54 | 55 | export function isCRUDEvent( 56 | event: StoreEvent 57 | ): event is CRUDEvent { 58 | return ( 59 | event.type === EventType.Get || 60 | event.type === EventType.Set || 61 | event.type === EventType.Update || 62 | event.type === EventType.Remove 63 | ); 64 | } 65 | 66 | export function isTransactionEvent( 67 | event: StoreEvent 68 | ): event is TransactionEvent { 69 | return event.type === EventType.Transaction; 70 | } 71 | -------------------------------------------------------------------------------- /__tests__/utils/computePathLineage.spec.ts: -------------------------------------------------------------------------------- 1 | import { computePathLineage } from "../../src"; 2 | 3 | describe("computePathLineage", function() { 4 | test("handles a scalar state", function() { 5 | const expectedPathFamily = new Set([[]]); 6 | expect(computePathLineage([], 100)).toEqual( 7 | expectedPathFamily 8 | ); 9 | }); 10 | 11 | test("compute the ancestor paths of a deep scalar", function() { 12 | type State = { 13 | a: { 14 | b: { 15 | c: number; 16 | }; 17 | }; 18 | }; 19 | 20 | const expectedPathFamily = new Set([ 21 | [], 22 | ["a"], 23 | ["a", "b"], 24 | ["a", "b", "c"] 25 | ]); 26 | 27 | expect(computePathLineage(["a", "b", "c"], 100)).toEqual( 28 | expectedPathFamily 29 | ); 30 | }); 31 | 32 | test("compute descendant paths of a complex tree", function() { 33 | type State = { 34 | a: Array; 35 | b: { 36 | c: number; 37 | }; 38 | d: bigint; 39 | }; 40 | 41 | const state: State = { 42 | a: ["one", "two"], 43 | b: { 44 | c: 100 45 | }, 46 | d: BigInt(100) 47 | }; 48 | 49 | const expectedPathFamily = new Set([ 50 | [], 51 | ["a"], 52 | ["a", "0"], 53 | ["a", "1"], 54 | ["b"], 55 | ["b", "c"], 56 | ["d"] 57 | ]); 58 | 59 | expect(computePathLineage([], state)).toEqual( 60 | expectedPathFamily 61 | ); 62 | }); 63 | 64 | test("complex tree in the middle of the state tree", function() { 65 | type C = { 66 | e: Array; 67 | g: number; 68 | }; 69 | 70 | type State = { 71 | a: { 72 | b: { 73 | c: C; 74 | }; 75 | }; 76 | }; 77 | 78 | const value: C = { 79 | e: ["one", "two"], 80 | g: 100 81 | }; 82 | 83 | const expectedPathFamily = new Set([ 84 | [], 85 | ["a"], 86 | ["a", "b"], 87 | ["a", "b", "c"], 88 | ["a", "b", "c", "e"], 89 | ["a", "b", "c", "e", "0"], 90 | ["a", "b", "c", "e", "1"], 91 | ["a", "b", "c", "g"] 92 | ]); 93 | 94 | expect(computePathLineage(["a", "b", "c"], value)).toEqual( 95 | expectedPathFamily 96 | ); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gact/store", 3 | "version": "0.0.0-rc.6", 4 | "description": "Accountable centralized state tree", 5 | "keywords": [ 6 | "store", 7 | "gact", 8 | "react" 9 | ], 10 | "main": "dist/store.umd.js", 11 | "module": "dist/store.es5.js", 12 | "typings": "dist/types/index.d.ts", 13 | "files": [ 14 | "dist" 15 | ], 16 | "author": "Mateusz Okon ", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/gactjs/store" 20 | }, 21 | "license": "MIT", 22 | "engines": { 23 | "node": ">=12.12.0" 24 | }, 25 | "scripts": { 26 | "lint": "yarn eslint . --ext .js,.jsx,.ts,.tsx", 27 | "lint:fix": "yarn eslint src --fix --ext .js,.jsx,.ts,.tsx", 28 | "build": "rollup -c rollup.config.js", 29 | "start": "rollup -c rollup.config.js -w", 30 | "test": "jest --coverage", 31 | "test:watch": "jest --coverage --watch", 32 | "report-coverage": "cat ./coverage/lcov.info | coveralls" 33 | }, 34 | "husky": { 35 | "hooks": { 36 | "pre-commit": "yarn prettier --write {src,test}/**/*.ts" 37 | } 38 | }, 39 | "jest": { 40 | "transform": { 41 | ".(ts|tsx)": "ts-jest" 42 | }, 43 | "testEnvironment": "jsdom", 44 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", 45 | "moduleFileExtensions": [ 46 | "ts", 47 | "tsx", 48 | "js" 49 | ], 50 | "coveragePathIgnorePatterns": [ 51 | "/node_modules/", 52 | "/test/" 53 | ], 54 | "coverageThreshold": { 55 | "global": { 56 | "branches": 90, 57 | "functions": 95, 58 | "lines": 95, 59 | "statements": 95 60 | } 61 | }, 62 | "collectCoverageFrom": [ 63 | "src/**/*.{js,jsx,ts,tsx}" 64 | ], 65 | "globals": { 66 | "ts-jest": { 67 | "diagnostics": false 68 | } 69 | } 70 | }, 71 | "devDependencies": { 72 | "@types/jest": "^26.0.9", 73 | "@typescript-eslint/eslint-plugin": "^2.22.0", 74 | "@typescript-eslint/parser": "^2.22.0", 75 | "coveralls": "^3.0.7", 76 | "eslint": "^6.8.0", 77 | "eslint-config-prettier": "^6.10.0", 78 | "eslint-plugin-prettier": "^3.1.2", 79 | "eslint-plugin-sort-keys-fix": "^1.1.1", 80 | "eslint-plugin-tsdoc": "^0.2.3", 81 | "husky": "^3.0.9", 82 | "jest": "^26.4.0", 83 | "prettier": "^1.19.1", 84 | "rollup": "^1.26.5", 85 | "rollup-plugin-sourcemaps": "^0.4.2", 86 | "rollup-plugin-typescript2": "^0.25.2", 87 | "ts-jest": "^25.3.1", 88 | "typescript": "^3.9.7" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/utils/clone.ts: -------------------------------------------------------------------------------- 1 | import { Complex, StoreValue } from "../types"; 2 | import { cloneComplex } from "./cloneComplex"; 3 | import { isContainer } from "./isContainer"; 4 | import { isPrimitive } from "./isPrimitive"; 5 | import { warning } from "./warning"; 6 | 7 | function cloneHelper( 8 | value: T, 9 | clonedValues: Set 10 | ): T { 11 | if (isPrimitive(value)) { 12 | return value; 13 | } 14 | 15 | // ensure reference-agnosticism 16 | if (clonedValues.has(value)) { 17 | throw Error("StoreValues must be reference agnostic"); 18 | } 19 | 20 | const result = cloneComplex(value as Complex); 21 | clonedValues.add(value); 22 | 23 | if (Object.getOwnPropertySymbols(value).length) { 24 | // warn instead of throw because jsdom adds a symbol property to File and Blob 25 | warning("Cannot clone a value with symbol properties"); 26 | } 27 | 28 | if (!isContainer(value) && Object.getOwnPropertyNames(value).length) { 29 | throw Error("Only Containers are allowed to have ownProperties"); 30 | } 31 | 32 | const descriptors = Object.getOwnPropertyDescriptors(value); 33 | 34 | // the one exception to the default descriptor invariant 35 | if (Array.isArray(value)) { 36 | delete descriptors.length; 37 | } 38 | 39 | for (const [key, descriptor] of Object.entries(descriptors)) { 40 | if (descriptor.get || descriptor.set) { 41 | throw Error("Cannot clone a property with getter and/or setter"); 42 | } 43 | 44 | if ( 45 | !(descriptor.configurable && descriptor.enumerable && descriptor.writable) 46 | ) { 47 | throw Error("ownProperties must have the default descriptor"); 48 | } 49 | 50 | Reflect.set(result, key, cloneHelper(descriptor.value, clonedValues)); 51 | } 52 | 53 | return result as T; 54 | } 55 | 56 | /** 57 | * Creates a perfect deep clone 58 | * 59 | * @remarks 60 | * `clone` ensures the provided value conforms to the `StoreValue` invariants. 61 | * 62 | * Every value is cloned before entering the store, and thus every value in the store 63 | * is guaranteed to conform to the `StoreValue` invariants. 64 | * 65 | * The `StoreValue` invariants are 66 | * 1. value is of type `StoreValue` 67 | * 2. value is reference agnostic 68 | * 3. only containers have ownProperties 69 | * 4. all properties have the default descriptor 70 | * 71 | * 72 | * @typeParam T - the value being cloned 73 | */ 74 | export function clone(value: T): T { 75 | const clonedValues: Set = new Set(); 76 | 77 | return cloneHelper(value, clonedValues); 78 | } 79 | -------------------------------------------------------------------------------- /CODE-OF-CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at teuszokon@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /__tests__/utils/createReadManager.spec.ts: -------------------------------------------------------------------------------- 1 | import { Path } from "../../src/types"; 2 | import { createReadManager } from "../../src/utils/createReadManager"; 3 | 4 | describe("createReadManager", function() { 5 | type State = { 6 | a: number; 7 | b: Array; 8 | c: { 9 | d: boolean; 10 | }; 11 | }; 12 | 13 | describe("Primitive", function() { 14 | test("cloning a Primitive returns the Primitive", function() { 15 | const readManager = createReadManager(); 16 | 17 | expect(readManager.clone(["a"], 100)).toBe(100); 18 | }); 19 | }); 20 | 21 | describe("Complex", function() { 22 | test("produces a perfect clone", function() { 23 | const readManager = createReadManager(); 24 | const arr = [0, 1, 2]; 25 | const arrClone = readManager.clone(["b"], arr); 26 | 27 | expect(arr).not.toBe(arrClone); 28 | expect(arr).toStrictEqual(arrClone); 29 | }); 30 | 31 | test("readManager reuses previous clones", function() { 32 | const readManager = createReadManager(); 33 | const arr = [0, 1, 2]; 34 | const arrClone = readManager.clone(["b"], arr); 35 | 36 | expect(readManager.clone(["b"], arr)).toBe(arrClone); 37 | }); 38 | 39 | test("clones are frozen", function() { 40 | const readManager = createReadManager(); 41 | const arr = [0, 1, 2]; 42 | const arrClone = readManager.clone(["b"], arr); 43 | 44 | expect(function() { 45 | arrClone[0] = 1; 46 | }).toThrow(); 47 | }); 48 | 49 | test("reconciliation", function() { 50 | const readManager = createReadManager(); 51 | const arr = [0, 1, 2]; 52 | const arrClone = readManager.clone(["b"], arr); 53 | const paths: Set> = new Set([["b"]]); 54 | 55 | readManager.reconcile(paths); 56 | expect(readManager.clone(["b"], arr)).not.toBe(arrClone); 57 | }); 58 | }); 59 | 60 | describe("deep tree", function() { 61 | const a = 100; 62 | const b = [0, 1, 2]; 63 | const c = { d: true }; 64 | const state: State = { a, b, c }; 65 | 66 | test("produces a perfect clone", function() { 67 | const readManager = createReadManager(); 68 | const stateClone = readManager.clone([], state); 69 | 70 | expect(state).not.toBe(stateClone); 71 | 72 | expect(state.a).toBe(stateClone.a); 73 | 74 | expect(state.b).not.toBe(stateClone.b); 75 | expect(state.b).toStrictEqual(stateClone.b); 76 | 77 | expect(state.c).not.toBe(stateClone.c); 78 | expect(state.c).toStrictEqual(stateClone.c); 79 | }); 80 | 81 | test("reuses previous clones", function() { 82 | const readManager = createReadManager(); 83 | const stateClone = readManager.clone([], state); 84 | const bClone = readManager.clone(["b"], b); 85 | const cClone = readManager.clone(["c"], c); 86 | 87 | expect(bClone).toBe(stateClone.b); 88 | expect(cClone).toBe(stateClone.c); 89 | }); 90 | 91 | test("clones are frozen", function() { 92 | const readManager = createReadManager(); 93 | const stateClone = readManager.clone([], state); 94 | 95 | // assert that they are frozen 96 | expect(function() { 97 | stateClone.c.d = false; 98 | }).toThrow(); 99 | }); 100 | 101 | test("reset", function() { 102 | const readManager = createReadManager(); 103 | const bClone = readManager.clone(["b"], b); 104 | const cClone = readManager.clone(["c"], c); 105 | readManager.reset(); 106 | const stateClone = readManager.clone([], state); 107 | 108 | // creates new clones of a and b after 109 | expect(bClone).not.toBe(stateClone.b); 110 | expect(cClone).not.toBe(stateClone.c); 111 | }); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # store 2 | 3 | ![CircleCI](https://img.shields.io/circleci/build/github/gactjs/store?style=for-the-badge) 4 | ![Coveralls github](https://img.shields.io/coveralls/github/gactjs/store?style=for-the-badge) 5 | ![GitHub](https://img.shields.io/github/license/gactjs/store?style=for-the-badge) 6 | ![npm](https://img.shields.io/npm/v/@gact/store?style=for-the-badge) 7 | ![npm bundle size](https://img.shields.io/bundlephobia/min/@gact/store?style=for-the-badge) 8 | 9 | The [Gact store](https://github.com/gactjs/store/blob/master/docs/white-paper.md) combines a carefully engineered `StoreValue` and **access layer** to achieve: a **decoupled state interface**, **serializability**, **immutability**, **exact change tracking**, and **event sourcing** with practically zero boilerplate and overhead. The fusion of the aforementioned features forms the only suitable state model for UIs: an **accountable centralized state tree**. 10 | 11 | ## API 12 | 13 | ### `createStore(initialState)` 14 | 15 | Creates a Gact store. 16 | 17 | #### Arguments 18 | 19 | 1. initialState (`StoreValue`) 20 | 21 | #### Returns 22 | 23 | (`Store`): A Gact store, which holds the complete state tree of your app. 24 | 25 | You interact with your state through the store's **access layer**, which is comprised of the following functions: 26 | 27 | - `path`: constructs a path, which declares the value you want to operate on 28 | - `get`: reads a value from the store 29 | - `set`: sets a value in the store 30 | - `update`: updates a value in the store 31 | - `remove`: removes a value from the store 32 | - `transaction`: allows you to compose the four CRUD operations into an atomic operation 33 | 34 | #### Example 35 | 36 | ```ts 37 | import { createStore } from "@gact/store"; 38 | 39 | type State = { 40 | count: number; 41 | balances: Record; 42 | }; 43 | 44 | const initialState: State = { 45 | count: 0, 46 | balances: { 47 | john: 1000, 48 | jane: 500, 49 | bad: 1000000000 50 | } 51 | }; 52 | 53 | // create a store 54 | const store = createStore(initialState); 55 | 56 | // destructure the core interface 57 | const { path, get, set, update, remove, transaction } = store; 58 | 59 | // read a value 60 | const count = get(path("count")); 61 | 62 | // set a value 63 | set(path("count"), 100); 64 | 65 | // update a value 66 | update(path("count"), c => c + 50); 67 | 68 | // remove a value 69 | remove(path("balances", "bad")); 70 | 71 | // create a complex atomic operation with transaction 72 | transaction(function() { 73 | const count = get(path("count")); 74 | const johnBalance = get(path("balances", "john")); 75 | if (johnBalance > count) { 76 | update(path("balances", "john"), b => b - count); 77 | update(path("balances", "jane"), b => b + count); 78 | } 79 | }); 80 | ``` 81 | 82 | ### `computePathLineage(path, value)` 83 | 84 | Computes the set of paths containing the path itself, ancestors, and descendants. 85 | 86 | `computePathLineage` will generally be used by libraries that implement reactivity with **exact change tracking**. 87 | 88 | #### Arguments 89 | 90 | 1. path (`Path`): the path of the value 91 | 2. value (`StoreValue`): the value at the given path 92 | 93 | #### Returns 94 | 95 | (`Set`): the set of paths containing the path itself, ancestors, and descendants. 96 | 97 | #### Example 98 | 99 | ```ts 100 | import { computePathLineage } from "@gact/store" 101 | 102 | const value = { 103 | c: { 104 | d: true 105 | } 106 | e: [0], 107 | f: "d" 108 | } 109 | 110 | const state = { 111 | a: { 112 | b: value, 113 | ... 114 | }, 115 | ... 116 | } 117 | 118 | // Set {[], ["a"], ["a", "b"], ["a", "b", "c"], ["a", "b", "c", "d"], ["a", "b", "e"], ["a", "b", "e", 0], ["a", "b", "f"] } 119 | const pathLineage = computePathLineage(["a", "b"], value); 120 | ``` 121 | 122 | ## Further Reading 123 | 124 | - [Gact Store White Paper](https://github.com/gactjs/store/blob/master/docs/white-paper.md) 125 | - [Death of Component State](https://github.com/gactjs/store/blob/master/docs/death-of-component-state.md) 126 | -------------------------------------------------------------------------------- /__tests__/utils/eventTypeGuards.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EventType, 3 | GetEvent, 4 | InitEvent, 5 | RemoveEvent, 6 | SetEvent, 7 | StoreRecord, 8 | TransactionEvent, 9 | UpdateEvent 10 | } from "../../src/types"; 11 | import { 12 | isCRUDEvent, 13 | isGetEvent, 14 | isInitEvent, 15 | isRemoveEvent, 16 | isSetEvent, 17 | isTransactionEvent, 18 | isUpdateEvent, 19 | isWriteEvent 20 | } from "../../src/utils/eventTypeGuards"; 21 | 22 | describe("eventTypeGuards", function() { 23 | type State = { 24 | a: string; 25 | b: StoreRecord; 26 | }; 27 | 28 | const initialState: State = { a: "a", b: { c: 100 } }; 29 | 30 | const initEvent: InitEvent = { 31 | state: initialState, 32 | type: EventType.Init 33 | }; 34 | 35 | const getEvent: GetEvent = { 36 | meta: null, 37 | path: ["a"], 38 | type: EventType.Get, 39 | value: "a" 40 | }; 41 | 42 | const setEvent: SetEvent = { 43 | meta: null, 44 | path: ["a"], 45 | prevValue: "a", 46 | type: EventType.Set, 47 | value: "b" 48 | }; 49 | 50 | const updateEvent: UpdateEvent = { 51 | meta: null, 52 | path: ["a"], 53 | prevValue: "a", 54 | type: EventType.Update, 55 | value: "b" 56 | }; 57 | 58 | const removeEvent: RemoveEvent = { 59 | meta: null, 60 | path: ["b", "c"], 61 | prevValue: 100, 62 | type: EventType.Remove 63 | }; 64 | 65 | const transactionEvent: TransactionEvent = { 66 | events: [getEvent, setEvent], 67 | meta: null, 68 | type: EventType.Transaction 69 | }; 70 | 71 | test("isInitEvent", function() { 72 | expect(isInitEvent(initEvent)).toBe(true); 73 | expect(isInitEvent(getEvent)).toBe(false); 74 | expect(isInitEvent(setEvent)).toBe(false); 75 | expect(isInitEvent(updateEvent)).toBe(false); 76 | expect(isInitEvent(removeEvent)).toBe(false); 77 | expect(isInitEvent(transactionEvent)).toBe(false); 78 | }); 79 | 80 | test("isGetEvent", function() { 81 | expect(isGetEvent(initEvent)).toBe(false); 82 | expect(isGetEvent(getEvent)).toBe(true); 83 | expect(isGetEvent(setEvent)).toBe(false); 84 | expect(isGetEvent(updateEvent)).toBe(false); 85 | expect(isGetEvent(removeEvent)).toBe(false); 86 | expect(isGetEvent(transactionEvent)).toBe(false); 87 | }); 88 | 89 | test("isSetEvent", function() { 90 | expect(isSetEvent(initEvent)).toBe(false); 91 | expect(isSetEvent(getEvent)).toBe(false); 92 | expect(isSetEvent(setEvent)).toBe(true); 93 | expect(isSetEvent(updateEvent)).toBe(false); 94 | expect(isSetEvent(removeEvent)).toBe(false); 95 | expect(isSetEvent(transactionEvent)).toBe(false); 96 | }); 97 | 98 | test("isUpdateEvent", function() { 99 | expect(isUpdateEvent(initEvent)).toBe(false); 100 | expect(isUpdateEvent(getEvent)).toBe(false); 101 | expect(isUpdateEvent(setEvent)).toBe(false); 102 | expect(isUpdateEvent(updateEvent)).toBe(true); 103 | expect(isUpdateEvent(removeEvent)).toBe(false); 104 | expect(isUpdateEvent(transactionEvent)).toBe(false); 105 | }); 106 | 107 | test("isRemoveEvent", function() { 108 | expect(isRemoveEvent(initEvent)).toBe(false); 109 | expect(isRemoveEvent(getEvent)).toBe(false); 110 | expect(isRemoveEvent(setEvent)).toBe(false); 111 | expect(isRemoveEvent(updateEvent)).toBe(false); 112 | expect(isRemoveEvent(removeEvent)).toBe(true); 113 | expect(isRemoveEvent(transactionEvent)).toBe(false); 114 | }); 115 | 116 | test("isWriteEvent", function() { 117 | expect(isWriteEvent(initEvent)).toBe(false); 118 | expect(isWriteEvent(getEvent)).toBe(false); 119 | expect(isWriteEvent(setEvent)).toBe(true); 120 | expect(isWriteEvent(updateEvent)).toBe(true); 121 | expect(isWriteEvent(removeEvent)).toBe(true); 122 | expect(isWriteEvent(transactionEvent)).toBe(false); 123 | }); 124 | 125 | test("isCRUDEvent", function() { 126 | expect(isCRUDEvent(initEvent)).toBe(false); 127 | expect(isCRUDEvent(getEvent)).toBe(true); 128 | expect(isCRUDEvent(setEvent)).toBe(true); 129 | expect(isCRUDEvent(updateEvent)).toBe(true); 130 | expect(isCRUDEvent(removeEvent)).toBe(true); 131 | expect(isCRUDEvent(transactionEvent)).toBe(false); 132 | }); 133 | 134 | test("isTransactionEvent", function() { 135 | expect(isTransactionEvent(initEvent)).toBe(false); 136 | expect(isTransactionEvent(getEvent)).toBe(false); 137 | expect(isTransactionEvent(setEvent)).toBe(false); 138 | expect(isTransactionEvent(updateEvent)).toBe(false); 139 | expect(isTransactionEvent(removeEvent)).toBe(false); 140 | expect(isTransactionEvent(transactionEvent)).toBe(true); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /__tests__/utils/clone.spec.ts: -------------------------------------------------------------------------------- 1 | import { clone } from "../../src/utils/clone"; 2 | 3 | describe("clone", function() { 4 | test("clone string", function() { 5 | const value = "value"; 6 | expect(clone(value)).toBe(value); 7 | }); 8 | 9 | test("clone number", function() { 10 | const value = 100; 11 | expect(clone(value)).toBe(value); 12 | }); 13 | 14 | test("clone bigint", function() { 15 | const value = BigInt(100); 16 | expect(clone(value)).toBe(value); 17 | }); 18 | 19 | test("clone boolean", function() { 20 | const value = true; 21 | expect(clone(value)).toBe(value); 22 | }); 23 | 24 | test("clone null", function() { 25 | const value = null; 26 | expect(clone(value)).toBe(value); 27 | }); 28 | 29 | test("trying to clone an uncloneable value throws", function() { 30 | const value = new WeakMap(); 31 | expect(function() { 32 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 33 | clone(value as any); 34 | }).toThrowError("not cloneable"); 35 | }); 36 | 37 | test("clone Blob", function() { 38 | // silence false warning 39 | const consoleError = jest.spyOn(console, "error").mockImplementation(); 40 | 41 | const value = new Blob(["

Love

"], { type: "text/html" }); 42 | const valueClone = clone(value); 43 | expect(value).not.toBe(valueClone); 44 | expect(value).toStrictEqual(valueClone); 45 | 46 | consoleError.mockRestore(); 47 | }); 48 | 49 | test("clone File", function() { 50 | // silence false warning 51 | const consoleError = jest.spyOn(console, "error").mockImplementation(); 52 | 53 | const value = new File(["foo"], "foo.txt", { 54 | type: "text/plain" 55 | }); 56 | const valueClone = clone(value); 57 | expect(value).not.toBe(valueClone); 58 | expect(value).toStrictEqual(valueClone); 59 | 60 | consoleError.mockRestore(); 61 | }); 62 | 63 | test("clone Array", function() { 64 | const value = [1, 2, 3]; 65 | 66 | const valueClone = clone(value); 67 | expect(value).not.toBe(valueClone); 68 | expect(value).toStrictEqual(valueClone); 69 | }); 70 | 71 | test("clone deep Array", function() { 72 | const value = [ 73 | [1, 2, 3], 74 | [1, 2, 3] 75 | ]; 76 | const valueClone = clone(value); 77 | expect(value).not.toBe(valueClone); 78 | expect(value[0]).not.toBe(valueClone[0]); 79 | expect(value[1]).not.toBe(valueClone[1]); 80 | expect(value).toStrictEqual(valueClone); 81 | expect(value[0]).toStrictEqual(valueClone[0]); 82 | expect(value[1]).toStrictEqual(valueClone[1]); 83 | }); 84 | 85 | test("clone record", function() { 86 | const value = { 87 | hate: false, 88 | love: 100 89 | }; 90 | const valueClone = clone(value); 91 | expect(value).not.toBe(valueClone); 92 | expect(value).toStrictEqual(valueClone); 93 | }); 94 | 95 | test("clone deep record", function() { 96 | const value = { 97 | name: "Bob", 98 | school: { 99 | graduation: 1899, 100 | name: "Do U" 101 | } 102 | }; 103 | const valueClone = clone(value); 104 | expect(value).not.toBe(valueClone); 105 | expect(value).toStrictEqual(valueClone); 106 | expect(value.school).not.toBe(valueClone.school); 107 | expect(value.school).toStrictEqual(valueClone.school); 108 | }); 109 | 110 | test("ensures properties have the default descriptor", function() { 111 | const value = {}; 112 | Object.defineProperty(value, "truth", { 113 | configurable: false, 114 | enumerable: false, 115 | value: 100, 116 | writable: false 117 | }); 118 | expect(function() { 119 | clone(value); 120 | }).toThrowError("must have the default descriptor"); 121 | }); 122 | 123 | test("trying to clone a property with a getter and/or setter throws", function() { 124 | const value = {}; 125 | Object.defineProperty(value, "truth", { 126 | get() { 127 | return 100; 128 | } 129 | }); 130 | expect(function() { 131 | clone(value); 132 | }).toThrowError("Cannot clone a property with getter and/or setter"); 133 | }); 134 | 135 | test("trying to clone an object with symbol properties warns", function() { 136 | const consoleError = jest.spyOn(console, "error").mockImplementation(); 137 | const value = { 138 | [Symbol("symbol")]: true 139 | }; 140 | clone(value); 141 | 142 | expect(consoleError.mock.calls.length).toBe(1); 143 | expect(consoleError.mock.calls[0][0]).toBe( 144 | "Cannot clone a value with symbol properties" 145 | ); 146 | consoleError.mockRestore(); 147 | }); 148 | 149 | test("trying to clone a value with internal references throws", function() { 150 | const value: Array> = [1, 2, 3]; 151 | value[0] = value as Array; 152 | expect(function() { 153 | clone(value); 154 | }).toThrow("must be reference agnostic"); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /docs/getting-started-react-gact-store-tutorial.md: -------------------------------------------------------------------------------- 1 | # Getting Started React + Gact Store Tutorial 2 | 3 | - [Introduction](#introduction) 4 | - [Setup](#setup) 5 | - [Create Store](#create-store) 6 | - [Create Component](#create-component) 7 | - [Render Component](#render-component) 8 | - [Conclusion](#conclusion) 9 | 10 | 11 | 12 | ## Introduction 13 | 14 | In this tutorial, we are going to build a very simple React app that consists of a `Counter`. The aim of this tutorial is to illustrate how to get started with the [Gact store](https://github.com/gactjs/store). 15 | 16 | 17 | 18 | ## Setup 19 | 20 | 1. Create a new React TypeScript project using create react app:
21 | `yarn create react-app gact-getting-started --template typescript` 22 | 23 | 2. Enter the project directory:
`cd gact-getting-started` 24 | 25 | 3. Add the [Gact store](https://github.com/gactjs/store) and the official [React bindings](https://github.com/gactjs/react-store) to your project:
26 | `yarn add @gact/store @gact/react-store` 27 | 28 | 4. Run `yarn start` to ensure that all went well, and you have a React app running locally. 29 | 30 |
31 | We are going to use TypeScript in this tutorial. I encourage you to follow along even if you only develop with JavaScript. 32 |
33 | 34 |
35 | Feel free you to use your preferred package runner instead of yarn (e.g. `npx`). 36 |
37 | 38 | 39 | 40 | ## Create Store 41 | 42 | The first thing we need to do is create our store. The store will be the centralized container for all of our application's state. 43 | 44 | Enter the `src` directory and create a `store.ts` file with the following code: 45 | 46 | ```ts 47 | import { createStore } from "@gact/store"; 48 | import { createBindings } from "@gact/react-store"; 49 | 50 | // declare the shape of our application's state tree 51 | export type State = { 52 | count: number; 53 | }; 54 | 55 | const initialState: State = { 56 | count: 0 57 | }; 58 | 59 | // create our store 60 | const store = createStore(initialState); 61 | 62 | // destructure and export the store's **access layer** 63 | // you use the **access layer** for all interaction with the Gact store 64 | export const { path, get, set, update, remove, transaction } = store; 65 | 66 | // create the React bindings for the Gact store and export `useValue` 67 | // `useValue` allows you to **reactively** read values from your store 68 | export const { useValue } = createBindings(store); 69 | ``` 70 | 71 | 72 | 73 | ## Create Component 74 | 75 | Now that we have a shiny new store. Let's create a component to interact with it. Make sure you are in your `src` directory. Create `Counter.tsx` with the following code: 76 | 77 | ```tsx 78 | import React from "react"; 79 | import { PathFor } from "@gact/store"; 80 | 81 | import { useValue, update, State } from "./store"; 82 | 83 | // The `PathFor` is the most important type provided by the Gact store. 84 | // It allows a component to declare the kind of state it needs without 85 | // tying it to a particular element in the state tree. 86 | type Props = { 87 | countPath: PathFor; // we need the path to a number (i.e our count) 88 | }; 89 | 90 | export default function Counter({ countPath }: Props) { 91 | // reactively read `count` from the store 92 | // reactively read = rerender this component when this value changes 93 | const count = useValue(countPath); 94 | 95 | function increment() { 96 | // we use the **update** function of the **access layer** to change values in the store 97 | update(countPath, c => c + 1); 98 | } 99 | 100 | function decrement() { 101 | update(countPath, c => c - 1); 102 | } 103 | 104 | return ( 105 |
106 | {count} 107 | 108 |
109 | ); 110 | } 111 | ``` 112 | 113 | 114 | 115 | ## Render Component 116 | 117 | We now have a `Counter` component that interacts with our `store`. Let's make sure everything works as expected by rendering our `Counter`. 118 | 119 | Open the `App.tsx` and replace the contents with: 120 | 121 | ```tsx 122 | import React from "react"; 123 | 124 | import { path } from "./store"; 125 | import Counter from "./Counter"; 126 | 127 | import "./App.css"; 128 | 129 | export default function App() { 130 | // we must use the **path** function of the **access layer** to create paths 131 | // you may notice that you get nice autocomplete and that TypeScript will prevent 132 | // you from passing in an invalid path 133 | return ( 134 |
135 | 136 |
137 | ); 138 | } 139 | ``` 140 | 141 | Now go to your browser, and you will see a working `Counter`! 142 | 143 | 144 | 145 | ## Conclusion 146 | 147 | Congratulations! You've successfully built an app with React and the [Gact store](https://github.com/gactjs/store). To learn more about the [Gact store](https://github.com/gactjs/store), please take a look at the [further reading](https://github.com/gactjs/store#further-reading). 148 | -------------------------------------------------------------------------------- /docs/death-of-component-state.md: -------------------------------------------------------------------------------- 1 | # Death of Component State 2 | 3 | - [Introduction](#introduction) 4 | - [Simple Component](#simple-component) 5 | - [Component State](#simple-component-component-state) 6 | - [Gact Store](#simple-component-gact-store) 7 | - [Analysis](#simple-component-analysis) 8 | - [Extended Requirements](#extended-requirements) 9 | - [Component State](#extended-requirements-component-state) 10 | - [Gact Store](#extended-requirements-gact-store) 11 | - [Analysis](#extended-requirements-analysis) 12 | - [Conclusion](#conclusion) 13 | 14 | 15 | 16 | ## Introduction 17 | 18 | [Component state](https://github.com/gactjs/gact/blob/master/docs/the-component-state-chimera.md) is the primary state model of every modern UI framework. The **component state model** is predicated on the misconception that UI state only has local implications and a predictable lifecycle. In truth, UI state has broad implications and an unpredictable lifecycle. **State hoisting**, the maneuver employed to conceal this reality, has a bevy of harmful ramifications: subversion of the component model, state obscurity, transition rule disintegration, inefficient reconciliation, and memory overhead. The [Gact store](https://github.com/gactjs/store) lets you avoid these problems by providing the only suitable state model for UIs: an **accountable centralized state tree**. 19 | 20 | 21 | 22 | ## Simple Component 23 | 24 | Let's start with a simple form example to compare the **component state model** with the [Gact store](https://github.com/gactjs/store). 25 | 26 | 27 | 28 | ### Component State 29 | 30 | ```ts 31 | import React, { useState } from "react"; 32 | 33 | function Reservation() { 34 | const [isGoing, setIsGoing] = useState(true); 35 | const [numberOfGuests, setNumberOfGuests] = useState(2); 36 | 37 | function handleIsGoingChange(event: React.ChangeEvent) { 38 | setIsGoing(event.target.checked); 39 | } 40 | 41 | function handleNumberOfGuestsChange( 42 | event: React.ChangeEvent 43 | ) { 44 | setNumberOfGuests(Number(event.target.value)); 45 | } 46 | 47 | return ( 48 |
49 | 58 |
59 | 68 |
69 | ); 70 | } 71 | ``` 72 | 73 | 74 | 75 | ### Gact Store 76 | 77 | You must first create the Gact store and the React bindings: 78 | 79 | ```ts 80 | import { createStore } from "@gact/store"; 81 | import { createBindings } from "@gact/react-store"; 82 | 83 | export type State = { 84 | isGoing: boolean; 85 | numberOfGuests: number; 86 | }; 87 | 88 | const store = createStore(initialState); 89 | 90 | // destructure and export the access layer 91 | export const { path, get, set, update, remove, transaction } = store; 92 | 93 | // destructure and export the React bindings 94 | export const { useValue, withStore } = createBindings(store); 95 | ``` 96 | 97 | ```ts 98 | import React from "react"; 99 | 100 | import { set, useValue } from "store"; 101 | 102 | type Props = { 103 | isGoingPath: PathFor; 104 | numberOfGuestsPath: PathFor; 105 | }; 106 | 107 | function Reservation({ isGoingPath, numberOfGuestsPath }: Props) { 108 | const isGoing = useValue(isGoingPath); 109 | const numberOfGuests = useValue(numberOfGuestsPath); 110 | 111 | function handleIsGoingChange(event: React.ChangeEvent) { 112 | set(isGoingPath, event.target.checked); 113 | } 114 | 115 | function handleNumberOfGuestsChange( 116 | event: React.ChangeEvent 117 | ) { 118 | set(numberOfGuestsPath, Number(event.target.value)); 119 | } 120 | 121 | return ( 122 |
123 | 132 |
133 | 142 |
143 | ); 144 | } 145 | ``` 146 | 147 | 148 | 149 | ### Analysis 150 | 151 | With just a simple `Reservation` form, the **component state model** is obviously superior. We declare state right inside our component and use it directly. In contrast, we have to create a [Gact Store](https://github.com/gactjs/store) and use `path`s to consume our state. 152 | 153 | 154 | 155 | ## Extended Requirements 156 | 157 | When we consider a simple component in isolation, the **component state model** shines because the assumptions underlying it hold: state only has local implications and a predictable lifecycle. However, real UI state has broad implications and an unpredictable lifecycle. 158 | 159 | Let's extend our `Reservation` form into a more complete reservation management system as follows: 160 | 161 | - add a `CostEstimator`, which displays the expected cost for attending the event with the specified number of guests. 162 | - add an event details page 163 | 164 | 165 | 166 | ### Component State 167 | 168 | These new requirements pose some challenges for the **component state model**. 169 | 170 | - The `CostEstimator` needs access to `numberOfGuests`, but that state is imprisoned within the `Reservation` component. 171 | - When the user navigates to the event details page, the `Reservation` form will be unmounted and its state cleared. 172 | 173 | The workaround employed whenever we discover state has broader implications or state and instance lifecycles diverge is **state hoisting**. **State hoisting** is the movement of state to an ancestor. 174 | 175 | We want both the `CostEstimator` and `Reservation` components to have access to `numberOfGuests`. If we move `numberOfGuests` to a common ancestor, then both the `CostEstimator` and `Reservation` components can be given access to `numberOfGuests`. 176 | 177 | ```ts 178 | import React, { useState } from "react"; 179 | 180 | import CostEstimator from "..."; 181 | import Reservation from "..."; 182 | 183 | function ReservationManagement() { 184 | const [numberOfGuests, setNumberOfGuests] = useState(2); 185 | 186 | return ( 187 |
188 | 189 | 193 |
194 | ); 195 | } 196 | ``` 197 | 198 | ```ts 199 | import React, { useState } from "react"; 200 | 201 | type Props = { 202 | numberOfGuests: number; 203 | setNumberOfGuests: React.Dispatch>; 204 | }; 205 | 206 | function Reservation({ numberOfGuests, setNumberOfGuests }: Props) { 207 | const [isGoing, setIsGoing] = useState(true); 208 | 209 | function handleIsGoingChange(event: React.ChangeEvent) { 210 | setIsGoing(event.target.checked); 211 | } 212 | 213 | function handleNumberOfGuestsChange( 214 | event: React.ChangeEvent 215 | ) { 216 | setNumberOfGuests(Number(event.target.value)); 217 | } 218 | 219 | return ( 220 |
221 | 230 |
231 | 240 |
241 | ); 242 | } 243 | ``` 244 | 245 | To persist state while navigating to the event details page, we would have to hoist further. We would need to move the `numberOfGuest` and `isGoing` state to a common ancestor of both the `EventPage` and `ReservationManagement` components. 246 | 247 | 248 | 249 | ### Gact Store 250 | 251 | We do not have to change our `Reservation` component. We simply provide access to the `numberOfGuests` by providing it's `path` to our `CostEstimator`. 252 | 253 | ```ts 254 | import React, { useState } from "react"; 255 | 256 | import { path } from "store"; 257 | import Reservation from "..."; 258 | 259 | function ReservationManagement() { 260 | return ( 261 |
262 | 263 | 267 |
268 | ); 269 | } 270 | ``` 271 | 272 | Likewise, navigating to the events page requires no additonal work because in the Gact store state lifecycle is independent of component lifecycle. 273 | 274 | 275 | 276 | ### Analysis 277 | 278 | The [Gact Store](https://github.com/gactjs/store) approach scales beautifully. We do not even have to touch our `Reservation` component. We simply create our `CostEstimator` and give it access to `numberOfGuests` via a `path`. Further, we can support arbitrary lifecyles for our state. 279 | 280 | In order to support the same features, the **component state model** requires several rounds of **state hoisting**. At first glance, **state hoisting** may seem like a fine way to deal with the limitations of component state. At a closer inspection, however, we see that **state hoisting** has a bevy of harmful ramifications: 281 | 282 | #### Subverts the Component Model 283 | 284 | A component should encapsulate a piece of an interface, and compose well with other such pieces. 285 | 286 | Hoisting breaks encapsulation by leaking state management details. After hoisting `numberOfGuest`, we cannot understand the `Reservation` component in isolation. We must always consider it alongside the `ReservationManagement` component. 287 | 288 | Hoisting hinders composition by encoding assumptions about component usage. After hoisting `numberOfGuest`, we can only use `Reservation` in a context where an ancestor manages `numberOfGuest`. 289 | 290 | #### Obscures State 291 | 292 | State hoisting obscures the state that impacts a given instance. We have to consider the state owned by the instance plus the state owned by all of its ancestors. Further, we have to consider how ancestors' state is transformed as it travels from ancestors to the instance in question! 293 | 294 | #### Transition Rule Disintegration 295 | 296 | State hoisting complicates reasoning about transitions by fostering **transition rule disintegration**: distance between the two elements of a transition rule (i.e update and event). 297 | 298 | Let's consider the transition rule for `numberOfGuests`. The event that triggers an update of `numberOfGuests` is a change to the corresponding input located in the `Reservation` component. The write triggered on that event is defined in `ReservationManagement`. This disintegration makes the transition more difficult to understand, we again have to reason about `ReservationManagement` and `Reservation` simultaneously. 299 | 300 | #### Inefficient Reconciliation 301 | 302 | State hoisting promotes inefficient reconciliation. 303 | 304 | The more we hoist state: 305 | 306 | - the bigger the subtrees we have to reconcile 307 | - the smaller the ratio: view that needs to be updated / view that was diffed 308 | 309 | In summary, a minor update with minor impact forces reconciliation of large subtrees. 310 | 311 | #### Memory Overhead 312 | 313 | The shackles of component state force state and state updates to travel along paths. These paths incur memory overhead. This is simple to see, we hold the state and props of every instance in memory. The `ReservationManagement` component stores `numberOfGuests` even though it never makes any real use of its value. 314 | 315 | #### It Only Gets Worse 316 | 317 | All the negative consequences of hoisting state get more dramatic the more we hoist: 318 | 319 | - Further subversion of the component model 320 | - Further obscured state 321 | - Further transition rule disintegration 322 | - Even more inefficient reconciliation 323 | - Greater memory overhead 324 | 325 | The `Reservation` example is really just a toy example. But consider the principles discussed above for a large form with several sections as is typical in an enterprise application. 326 | 327 | 328 | 329 | ## Conclusion 330 | 331 | Component state is dead. Use the [Gact store](https://github.com/gactjs/store). 332 | -------------------------------------------------------------------------------- /src/createStore.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CRUDEvent, 3 | EventType, 4 | GetEvent, 5 | InitEvent, 6 | Listener, 7 | PathFor, 8 | RemoveEvent, 9 | SetEvent, 10 | Store, 11 | StoreEvent, 12 | StoreRecord, 13 | StoreValue, 14 | TransactionEvent, 15 | UpdateEvent, 16 | Updater 17 | } from "./types"; 18 | import { clone } from "./utils/clone"; 19 | import { computePathLineage } from "./utils/computePathLineage"; 20 | import { createPathFactory } from "./utils/createPathFactory"; 21 | import { createReadManager } from "./utils/createReadManager"; 22 | import { deepFreeze } from "./utils/deepFreeze"; 23 | import { getByPath } from "./utils/getByPath"; 24 | import { getContainer } from "./utils/getContainer"; 25 | 26 | function createInitEvent(state: S): InitEvent { 27 | return deepFreeze({ state, type: EventType.Init }); 28 | } 29 | 30 | function createGetEvent( 31 | path: PathFor, 32 | value: V, 33 | meta: StoreRecord | null 34 | ): GetEvent { 35 | return deepFreeze({ meta, path, type: EventType.Get, value }); 36 | } 37 | 38 | function createSetEvent( 39 | path: PathFor, 40 | prevValue: V | null, 41 | value: V, 42 | meta: StoreRecord | null 43 | ): SetEvent { 44 | return deepFreeze({ 45 | meta, 46 | path, 47 | prevValue, 48 | type: EventType.Set, 49 | value 50 | }); 51 | } 52 | 53 | function createUpdateEvent( 54 | path: PathFor, 55 | prevValue: V, 56 | value: V, 57 | meta: StoreRecord | null 58 | ): UpdateEvent { 59 | return deepFreeze({ 60 | meta, 61 | path, 62 | prevValue, 63 | type: EventType.Update, 64 | value 65 | }); 66 | } 67 | 68 | function createRemoveEvent( 69 | path: PathFor, 70 | prevValue: V, 71 | meta: StoreRecord | null 72 | ): RemoveEvent { 73 | return deepFreeze({ 74 | meta, 75 | path, 76 | prevValue, 77 | type: EventType.Remove 78 | }); 79 | } 80 | 81 | function createTransactionEvent( 82 | transactionEvents: Array>, 83 | meta: StoreRecord | null 84 | ): TransactionEvent { 85 | return deepFreeze({ 86 | events: transactionEvents, 87 | meta, 88 | type: EventType.Transaction 89 | }); 90 | } 91 | 92 | /** 93 | * Creates a Gact store. 94 | * 95 | * @typeParam S - the state tree; 96 | */ 97 | export function createStore(initialState: S): Store { 98 | let initialized = false; 99 | let state: S = clone(initialState); 100 | let transactionWrites: Array<() => void> = []; 101 | let transactionEvents: Array> = []; 102 | let activeUpdate = false; 103 | let activeTransaction = false; 104 | const { path, fromFactory } = createPathFactory(); 105 | const listeners: Set> = new Set(); 106 | const readManager = createReadManager(); 107 | 108 | /** 109 | * Distributes a `StoreEvent` to all listeners 110 | */ 111 | function notifyListeners( 112 | event: StoreEvent 113 | ): void { 114 | for (const listener of listeners) { 115 | listener(event as StoreEvent); 116 | } 117 | } 118 | 119 | /** 120 | * `announce` enhances `notifyListeners` with transaction-awareness. 121 | * 122 | * If we are in the middle of a transaction, then each event is added 123 | * to the transaction events. Otherwise, we notify listeners like normal. 124 | * 125 | */ 126 | function announce(event: CRUDEvent): void { 127 | if (activeTransaction) { 128 | transactionEvents.push((event as unknown) as CRUDEvent); 129 | } else { 130 | notifyListeners(event); 131 | } 132 | } 133 | 134 | /** 135 | * `makeInitAware` is a higher-order function that wraps the functions 136 | * of the **access layer** to ensure that the store's event stream 137 | * begins with an `InitEvent`. 138 | * 139 | * @typeParam T - the function being wrapped 140 | */ 141 | function makeInitAware unknown>(fn: T): T { 142 | return function(...args) { 143 | if (!initialized) { 144 | initialized = true; 145 | 146 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 147 | const readOnlyState = readManager.clone([] as any, state as any) as S; 148 | 149 | notifyListeners(createInitEvent(readOnlyState)); 150 | } 151 | 152 | return fn(...args); 153 | } as T; 154 | } 155 | 156 | /** 157 | * `makePathFactoryAware` is a higher-order function that wraps the CRUD 158 | * functions of the **access layer** to ensure only paths created by the 159 | * store's pathFactory are used. 160 | * 161 | * @typeParam T - the function being wrapped 162 | */ 163 | function makePathFactoryAware unknown>( 164 | fn: T 165 | ): T { 166 | return function(...args) { 167 | const path = args[0]; 168 | if (!fromFactory(path)) { 169 | throw Error("You must use paths created with store.path"); 170 | } 171 | 172 | return fn(...args); 173 | } as T; 174 | } 175 | 176 | /** 177 | * `makeUpdateAware` is a higher-order function that wraps writers to ensure 178 | * that an update never includes any other writes. 179 | * 180 | * @typeParam T - the writer being wrapped 181 | */ 182 | function makeUpdateAware void>(writer: T): T { 183 | return function(...args) { 184 | if (activeUpdate) { 185 | throw Error("An update cannot include other writes"); 186 | } 187 | 188 | writer(...args); 189 | } as T; 190 | } 191 | 192 | /** 193 | * `makeTransactionAware` is a higher-order function that wraps writers to provide 194 | * transaction-awareness. The execution of writes is delayed until the end of the 195 | * transaction. 196 | * 197 | * @typeParam T - the writer being wrapped 198 | */ 199 | function makeTransactionAware void>( 200 | writer: T 201 | ): T { 202 | return function(...args): void { 203 | if (activeTransaction) { 204 | transactionWrites.push(function() { 205 | writer(...args); 206 | }); 207 | } else { 208 | writer(...args); 209 | } 210 | } as T; 211 | } 212 | 213 | /** 214 | * `enhanceWriter` is a higher-order function that wraps a writer with pathFactory, 215 | * update, transaction, and init awareness. 216 | * 217 | * @typeParam T - the writer being wrapped 218 | */ 219 | function enhanceWriter void>(writer: T): T { 220 | return makeInitAware( 221 | makeTransactionAware(makeUpdateAware(makePathFactoryAware(writer))) 222 | ); 223 | } 224 | 225 | /** 226 | * Reads a value from the store. 227 | * 228 | * @remarks 229 | * The value returned is completely frozen (i.e immutable) 230 | * 231 | * @typeParam P - the path of the value 232 | * @typeParam V - the type of value at P 233 | */ 234 | function get( 235 | path: PathFor, 236 | meta: StoreRecord | null = null 237 | ): V { 238 | const value = readManager.clone(path, getByPath(state, path)); 239 | 240 | announce(createGetEvent(path, value, meta)); 241 | 242 | return value; 243 | } 244 | 245 | /** 246 | * Sets a value in the store. 247 | * 248 | * @remarks 249 | * The value passed to `set` is cloned to ensure immutability. 250 | * 251 | * @typeParam P - the path of the value 252 | * @typeParam V - the type of value at P 253 | */ 254 | function set( 255 | path: PathFor, 256 | value: V, 257 | meta: StoreRecord | null = null 258 | ): void { 259 | let prevValue: V | null = null; 260 | 261 | value = clone(value); //as Value; 262 | 263 | if (path.length === 0) { 264 | // will never use this `state` again, so it's okay if it gets frozen 265 | prevValue = (state as unknown) as V; 266 | 267 | readManager.reset(); 268 | state = (value as unknown) as S; 269 | } else { 270 | const container = getContainer(state, path); 271 | const key = path[path.length - 1]; 272 | 273 | if (Object.prototype.hasOwnProperty.call(container, key)) { 274 | prevValue = Reflect.get(container, key); 275 | 276 | readManager.reconcile(computePathLineage(path, prevValue)); 277 | } 278 | 279 | Reflect.set(container, key, value); 280 | } 281 | 282 | announce( 283 | createSetEvent(path, prevValue, readManager.clone(path, value), meta) 284 | ); 285 | } 286 | 287 | /** 288 | * Updates a value in the store. 289 | * 290 | * @remarks 291 | * The updated value, either the value passed to or returned from the updater, 292 | * is cloned to ensure immutability 293 | * 294 | * @typeParam P - the path of the value 295 | * @typeParam V - the type of value at P 296 | */ 297 | function update( 298 | path: PathFor, 299 | updater: Updater, 300 | meta: StoreRecord | null = null 301 | ): void { 302 | activeUpdate = true; 303 | 304 | let prevValue: V; 305 | let value: V; 306 | if (path.length === 0) { 307 | prevValue = readManager.clone(path, getByPath(state, path)); 308 | readManager.reset(); 309 | 310 | // will never use this `state` again, so we can allow it to be mutated directly 311 | value = (state as unknown) as V; 312 | const updatedValue = updater(value); 313 | if (updatedValue !== undefined) { 314 | value = updatedValue; 315 | } 316 | 317 | state = (clone(value) as unknown) as S; 318 | } else { 319 | const container = getContainer(state, path); 320 | const key = path[path.length - 1]; 321 | 322 | if (!Object.prototype.hasOwnProperty.call(container, key)) { 323 | throw Error(`${path} does not exist`); 324 | } 325 | 326 | value = Reflect.get(container, key); 327 | prevValue = readManager.clone(path, value); 328 | 329 | readManager.reconcile(computePathLineage(path, prevValue)); 330 | 331 | const updatedValue = updater(value); 332 | if (updatedValue !== undefined) { 333 | value = updatedValue; 334 | } 335 | 336 | value = clone(value); 337 | 338 | Reflect.set(container, key, value); 339 | } 340 | 341 | activeUpdate = false; 342 | 343 | announce( 344 | createUpdateEvent(path, prevValue, readManager.clone(path, value), meta) 345 | ); 346 | } 347 | 348 | /** 349 | * Removes a value from the store. 350 | * 351 | * @typeParam P - the path of the value 352 | * @typeParam V - the type of value at P 353 | */ 354 | function remove( 355 | path: PathFor, 356 | meta: StoreRecord | null = null 357 | ): void { 358 | if (path.length === 0) { 359 | throw Error("remove must be called with path.length >= 1"); 360 | } 361 | 362 | const container = getContainer(state, path); 363 | const key = path[path.length - 1]; 364 | const prevValue: V = Reflect.get(container, key); 365 | 366 | if (!Object.prototype.hasOwnProperty.call(container, key)) { 367 | throw Error(`${path} does not exist`); 368 | } 369 | 370 | Reflect.deleteProperty(container, key); 371 | 372 | readManager.reconcile(computePathLineage(path, prevValue)); 373 | 374 | announce(createRemoveEvent(path, prevValue, meta)); 375 | } 376 | 377 | /** 378 | * Lets you compose CRUD operations into an atomic operation. 379 | * 380 | * @remarks 381 | * Transactions must be synchronous. 382 | * 383 | * If you suspend your transaction (e.g. `await`), then all the CRUD events 384 | * that happen during suspension will be considered part of the transaction. 385 | * 386 | * If you try to add operations in a callback or `Promise` handler, then those 387 | * operations will not be included in the transaction. 388 | * 389 | * Gact cannot ensure that your transaction is synchronous. However, one way in which 390 | * a transaction's asynchrony will be revealed is attempting multiple simultaneous 391 | * transactions. 392 | */ 393 | function transaction( 394 | runTransaction: () => void, 395 | meta: StoreRecord | null = null 396 | ): void { 397 | if (activeUpdate) { 398 | throw Error("Cannot run a transaction during an update"); 399 | } 400 | 401 | if (activeTransaction) { 402 | throw Error( 403 | "Only one transaction can run at a time. Hint: make sure your transactions are synchronous" 404 | ); 405 | } 406 | 407 | activeTransaction = true; 408 | 409 | // process transaction 410 | runTransaction(); 411 | transactionWrites.forEach(function(write) { 412 | write(); 413 | }); 414 | 415 | const event = createTransactionEvent(transactionEvents, meta); 416 | 417 | // reset 418 | transactionWrites = []; 419 | transactionEvents = []; 420 | activeTransaction = false; 421 | 422 | notifyListeners(event); 423 | } 424 | 425 | /** 426 | * Ensures the integrity of the set of listeners. 427 | * 428 | * We cannot have the set of listeners change as we loop through them to notify 429 | * of a write. 430 | */ 431 | function canMutateSubscriptions(): boolean { 432 | return !activeUpdate && !activeTransaction; 433 | } 434 | 435 | /** 436 | * Subscribes the provided listener to the stream of store events. 437 | */ 438 | function subscribe(listener: Listener): () => void { 439 | if (!canMutateSubscriptions()) { 440 | throw Error("Cannot subscribe during an update or transaction"); 441 | } 442 | 443 | listeners.add(listener); 444 | 445 | return function unsubscribe(): void { 446 | if (!canMutateSubscriptions()) { 447 | throw Error("Cannot unsubscribe during an update or transaction"); 448 | } 449 | 450 | listeners.delete(listener); 451 | }; 452 | } 453 | 454 | return deepFreeze({ 455 | canMutateSubscriptions, 456 | get: makeInitAware(makePathFactoryAware(get)), 457 | path, 458 | remove: enhanceWriter(remove), 459 | set: enhanceWriter(set), 460 | subscribe, 461 | transaction: makeInitAware(transaction), 462 | update: enhanceWriter(update) 463 | }); 464 | } 465 | -------------------------------------------------------------------------------- /docs/decoupled-state-interface.md: -------------------------------------------------------------------------------- 1 | # Decoupled State Interface 2 | 3 | - [Introduction](#introduction) 4 | - [Redux](#redux) 5 | - [Single Counter Example](#redux-simple-counter-example) 6 | - [Many Counters Example](#redux-many-counters-example) 7 | - [External Component](#redux-external-component) 8 | - [Gact Store](#gact-store) 9 | - [Single Counter Example](#gact-store-simple-counter-example) 10 | - [Many Counters Example](#gact-store-many-counters-example) 11 | - [External Component](#gact-store-external-component) 12 | - [Comparison](#comparison) 13 | - [Conclusion](#conclusion) 14 | 15 | 16 | 17 | # Introduction 18 | 19 | Reusable code that relies on a global store must not encode assumptions about the state tree. A **decoupled state interface** allows you to interact with a global state tree in a general, resuable manner. In this article, we will explore the ways [Redux](https://github.com/reduxjs/redux) and the [Gact store](https://github.com/gactjs/store/) facilitate **decoupled state interface**s. 20 | 21 | 22 | 23 | ## Redux 24 | 25 | You read from [Redux](https://github.com/reduxjs/redux) with selectors. You write to [Redux](https://github.com/reduxjs/redux) by dispatching actions. Thus, a reusable component that relies on [Redux](https://github.com/reduxjs/redux) takes selectors and action creators as props. 26 | 27 | 28 | 29 | ### Single Counter Example 30 | 31 | Let's explore a `Counter` example: 32 | 33 | #### Store 34 | 35 | ```ts 36 | import { createSlice, configureStore } from "@reduxjs/toolkit"; 37 | 38 | export const counterSlice = createSlice({ 39 | name: "counter", 40 | initialState: 0, 41 | reducers: { 42 | increment(draft) { 43 | return draft + 1; 44 | }, 45 | decrement(draft) { 46 | return draft + 1; 47 | } 48 | } 49 | }); 50 | 51 | export const { actions } = counterSlice; 52 | 53 | export const store = configureStore({ 54 | reducer: counterSlice.reducer 55 | }); 56 | 57 | export type State = ReturnType; 58 | 59 | export const selectCount = (state: State) => state; 60 | ``` 61 | 62 | #### Component 63 | 64 | ```ts 65 | import React from "react"; 66 | import { useDispatch, useSelector } from "react-redux"; 67 | 68 | import { State } from "store"; 69 | 70 | type Props = { 71 | countSelector: (state: State) => number; 72 | actions: { 73 | increment: () => void; 74 | decrement: () => void; 75 | }; 76 | }; 77 | 78 | export default function Counter({ countSelector, actions }: Props) { 79 | const dispatch = useDispatch(); 80 | const count = useSelector(countSelector); 81 | 82 | function increment() { 83 | dispatch(actions.increment()); 84 | } 85 | 86 | function decrement() { 87 | dispatch(actions.decrement()); 88 | } 89 | 90 | return ( 91 |
92 | {count} 93 | 94 |
95 | ); 96 | } 97 | ``` 98 | 99 | #### Usage 100 | 101 | ```ts 102 | import * as React from "react"; 103 | import { Provider } from "react-redux"; 104 | 105 | import { store, actions, selectCount } from "store"; 106 | import Counter from "Counter"; 107 | 108 | export default function App() { 109 | return ( 110 | 111 | 112 | 113 | ); 114 | } 115 | ``` 116 | 117 | 118 | 119 | ### Many Counters Example 120 | 121 | Let's say our app needs many `Counter`s. Below we establish a pattern that we can use to support arbitrarily many `Counter`s. 122 | 123 | #### Store 124 | 125 | Notably, we create a `counterSliceFactory` to reuse our `counterSlice` creation code. 126 | 127 | ```ts 128 | function counterSliceFactory(name: string) { 129 | return createSlice({ 130 | name, 131 | initialState: 0, 132 | reducers: { 133 | increment(draft) { 134 | return count + 1; 135 | }, 136 | decrement(draft) { 137 | return count + 1; 138 | } 139 | } 140 | }); 141 | } 142 | 143 | const counterOneSlice = counterSliceFactory("counter-one"); 144 | const counterTwoSlice = counterSliceFactory("counter-two"); 145 | 146 | export const store = configureStore({ 147 | reducer: { 148 | countOne: counterOneSlice.reducer, 149 | countTwo: counterTwoSlice.reducer 150 | } 151 | }); 152 | 153 | export type State = ReturnType; 154 | 155 | export const { actions: countOneActions } = counterOneSlice; 156 | 157 | export const { actions: countTwoActions } = counterTwoSlice; 158 | 159 | export const selectCountOne = (state: State) => state.countOne; 160 | 161 | export const selectCountTwo = (state: State) => state.countTwo; 162 | ``` 163 | 164 | #### Usage 165 | 166 | ```ts 167 | import * as React from "react"; 168 | import { Provider } from "react-redux"; 169 | 170 | import { 171 | store, 172 | countOneActions, 173 | countTwoActions, 174 | selectCountOne, 175 | selectCountTwo 176 | } from "store"; 177 | import Counter from "Counter"; 178 | 179 | export default function App() { 180 | return ( 181 | 182 | 183 | 184 | 185 | ); 186 | } 187 | ``` 188 | 189 | 190 | 191 | ### External Component 192 | 193 | Let's package our `Counter` into an external package. The above examples were decoupled from particular elements in the state tree, but were still bound to a specific state tree (i.e our state tree). When we develop external components, we must not be tied to a particular state tree. 194 | 195 | #### Library 196 | 197 | We add a type parameter `S` so that our component can work with arbitrary state trees. 198 | 199 | ```ts 200 | import React from "react"; 201 | import { useDispatch, useSelector } from "react-redux"; 202 | 203 | type Props = { 204 | countSelector: (state: S) => number; 205 | actions: { 206 | increment: () => void; 207 | decrement: () => void; 208 | } 209 | } 210 | 211 | export function Counter({ countSelector, actions }: Props) { 212 | const dispatch = useDispatch(); 213 | const count = useSelector(countSelector); 214 | 215 | function increment() { 216 | dispatch(actions.increment()); 217 | } 218 | 219 | function decrement() { 220 | dispatch(actions.decrement()); 221 | } 222 | 223 | return ( 224 |
225 | {count} 226 | 227 |
228 | ); 229 | } 230 | 231 | export function counterSliceFactory(name: string) { 232 | return createSlice({ 233 | name, 234 | initialState: 0, 235 | reducers: { 236 | increment(draft) { 237 | return count + 1; 238 | }, 239 | decrement(draft) { 240 | return count - 1; 241 | }, 242 | }, 243 | }); 244 | } 245 | ``` 246 | 247 | #### Create Store 248 | 249 | This is almost the same as our many counters example expect that we use the `counterSliceFactory` provided by our library. 250 | 251 | ```ts 252 | import { configureStore } from "@reduxjs/toolkit"; 253 | import { counterSliceFactory } from "..."; 254 | 255 | const counterOneSlice = counterSliceFactory("counter-one"); 256 | const counterTwoSlice = counterSliceFactory("counter-two"); 257 | 258 | export const store = configureStore({ 259 | reducer: { 260 | countOne: counterOneSlice.reducer, 261 | countTwo: counterTwoSlice.reducer 262 | } 263 | }); 264 | 265 | export type State = ReturnType; 266 | 267 | export const { actions: countOneActions } = counterOneSlice; 268 | 269 | export const { actions: countTwoActions } = counterTwoSlice; 270 | 271 | export const selectCountOne = (state: State) => state.countOne; 272 | 273 | export const selectCountTwo = (state: State) => state.countTwo; 274 | ``` 275 | 276 | #### Usage 277 | 278 | The key difference here is that we have to specify the type of our state, `State`, as the concrete type for our library `Counter`'s `S` type parameter. 279 | 280 | ```ts 281 | import * as React from "react"; 282 | import { Provider } from "react-redux"; 283 | 284 | import { 285 | store, 286 | State, 287 | countOneActions, 288 | countTwoActions, 289 | selectCountOne, 290 | selectCountTwo 291 | } from "store"; 292 | import Counter from "Counter"; 293 | 294 | export default function App() { 295 | return ( 296 | 297 | selectCount={selectCountOne} actions={countOneActions} /> 298 | selectCount={selectCountTwo} actions={countTwoActions} /> 299 | 300 | ); 301 | } 302 | ``` 303 | 304 | 305 | 306 | ## Gact Store 307 | 308 | The [Gact store](https://github.com/gactjs/store/) was specifcally designed to provide a **decoupled state interface**. The core insight underlying the [Gact store's](https://github.com/gactjs/store/) **decoupled state interface** is: code that interacts with the store requires specific types of state, but is agnostic to the location of this state. Therfore, the [Gact store](https://github.com/gactjs/store/) provides: 309 | 310 | - a type to declare state requirements in a location-agnostic manner. 311 | - an **access layer** to operate on state in a location-agnostic manner. 312 | 313 | 314 | 315 | ### Single Counter Example 316 | 317 | #### Store 318 | 319 | ```ts 320 | import { createStore } from "@gact/store"; 321 | import { createBindings } from "@gact/react-store"; 322 | 323 | export type State = { 324 | count: number; 325 | }; 326 | 327 | const initialState: State = { 328 | count: 0 329 | }; 330 | 331 | const store = createStore(initialState); 332 | 333 | // destructure and export the access layer 334 | export const { path, get, set, update, remove, transaction } = store; 335 | 336 | // destructure and export the React bindings 337 | export const { useValue, withStore } = createBindings(store); 338 | ``` 339 | 340 | #### Component 341 | 342 | ```ts 343 | import React from "react"; 344 | import { PathFor } from "@gact/store"; 345 | 346 | import { useValue, path, update, State } from "store"; 347 | 348 | type Props = { 349 | countPath: PathFor; 350 | }; 351 | 352 | export default function Counter({ countPath }: Props) { 353 | const count = useValue(countPath); 354 | 355 | function increment() { 356 | update(countPath, c => c + 1); 357 | } 358 | 359 | function decrement() { 360 | update(countPath, c => c - 1); 361 | } 362 | 363 | return ( 364 |
365 | {count} 366 | 367 |
368 | ); 369 | } 370 | ``` 371 | 372 | #### Usage 373 | 374 | ```ts 375 | import React from "react"; 376 | 377 | import { path } from "store"; 378 | import Counter from "Counter"; 379 | 380 | export default function App() { 381 | return ; 382 | } 383 | ``` 384 | 385 | 386 | 387 | ### Many Counters Example 388 | 389 | Let's say our app needs many `Counter`s. The only thing we need to do to support many `Counter`s is define additional `count` elements in our state tree. 390 | 391 | #### Store 392 | 393 | ```ts 394 | import { createStore } from "@gact/store"; 395 | import { createBindings } from "@gact/react-store"; 396 | 397 | export type State = { 398 | countOne: number; 399 | countTwo: number; 400 | }; 401 | 402 | const initialState: State = { 403 | count: 0 404 | }; 405 | 406 | const store = createStore(initialState); 407 | 408 | // destructure and export the access layer 409 | export const { path, get, set, update, remove, transaction } = store; 410 | 411 | // destructure and export the React bindings 412 | export const { useValue, withStore } = createBindings(store); 413 | ``` 414 | 415 | #### Usage 416 | 417 | ```ts 418 | import React from "react"; 419 | 420 | import { path } from "store"; 421 | import Counter from "Counter"; 422 | 423 | export default function App() { 424 | return ( 425 | <> 426 | 427 | 428 | 429 | ); 430 | } 431 | ``` 432 | 433 | 434 | 435 | ### External Component Example 436 | 437 | As discussed in [Redux External Component](#redux-external-component), external components must not be tied to a particular state tree. The [Gact store](https://github.com/gactjs/store) supports this capabaility as well: 438 | 439 | #### Component 440 | 441 | We make use of the **create component pattern** to define a `Counter` that can be used by any app. 442 | 443 | ```ts 444 | import React from "react"; 445 | import { Store, StoreValue, PathFor } from "@gact/store"; 446 | import { ReactStore } from "@gact/react-store"; 447 | 448 | type Props = { 449 | countPath: PathFor; 450 | } 451 | 452 | export default function createCounter({ update, useValue}: ReactStore)) { 453 | return function Counter({ countPath }: Props) { 454 | const count = useValue(countPath); 455 | 456 | function increment() { 457 | update(countPath, c => c + 1); 458 | } 459 | 460 | function decrement() { 461 | update(countPath, c => c - 1); 462 | } 463 | 464 | return ( 465 |
466 | {count} 467 | 468 |
469 | ); 470 | }; 471 | } 472 | ``` 473 | 474 | #### Usage 475 | 476 | We create a `Counter` for our app by using `withStore`. 477 | 478 | ```ts 479 | import React from "react"; 480 | import createCounter from "..."; 481 | 482 | import { path, withStore } from "store"; 483 | 484 | const Counter = withStore(createCounter); 485 | 486 | function App() { 487 | return ( 488 | <> 489 | 490 | 491 | 492 | ); 493 | } 494 | ``` 495 | 496 | 497 | 498 | ## Comparison 499 | 500 | [Redux](https://github.com/reduxjs/redux) and the [Gact store](https://github.com/gactjs/store/) both facilitate **decoupled state interface**s. However, the [Gact store](https://github.com/gactjs/store/) was specifically designed to provide a **decoupled state interface**. As a result, the [Gact store](https://github.com/gactjs/store/) more scalably and elagantly supports decoupled state interaction: 501 | 502 | - The [Redux](https://github.com/reduxjs/redux) solution requires you to pass action creators to your components. The more complex your write logic, the more action creators you are going to need to pass. In contrast, with the [Gact store](https://github.com/gactjs/store/) approach your interface is essentially constant. 503 | - Passing action creators is a brittle interface. You can easily pass the wrong action creators, and TypeScript generally will be unable to help you. 504 | - The `selector` approach requires boilerplate that is absent from the Gact store: 505 | 506 | ```ts 507 | const selectCountOne = (state: State) => state.countOne; 508 | 509 | // vs 510 | path("countOne"); 511 | ``` 512 | 513 | - With the **create component pattern** you do not have to explicitly provide a `` type parameter when consuming external components 514 | 515 | 516 | 517 | ## Conclusion 518 | 519 | A **decoupled state interface** is the key to building reusable components that rely on a global store. The ability to create reusable components that rely on a global store lets you avoid [component state](https://github.com/gactjs/store/blob/master/docs/death-of-component-state.md) and promotes **state centralization**. 520 | -------------------------------------------------------------------------------- /docs/white-paper.md: -------------------------------------------------------------------------------- 1 | # Gact Store 2 | 3 | - [Introduction](#introduction) 4 | - [Other State Management Solutions](#other-state-management-solutions) 5 | - [Component State](#other-state-management-solutions-component-state) 6 | - [Context](#other-state-management-solutions-context) 7 | - [Redux](#other-state-management-solutions-redux) 8 | - [MobX](#other-state-management-solutions-mobx) 9 | - [Store Value](#store-value) 10 | - [Decoupled State Interface](#decoupled-state-interface) 11 | - [PathFor](#decoupled-state-interface-pathfor) 12 | - [Access Layer](#decoupled-state-interface-access-layer) 13 | - [Immutability](#immutability) 14 | - [Serializability](#serializability) 15 | - [Exact Change Tracking](#exact-change-tracking) 16 | - [State Centralization](#state-centralization) 17 | - [Components](#state-centralization-components) 18 | - [Event Sourcing](#event-sourcing) 19 | - [Conclusion](#conclusion) 20 | 21 | 22 | 23 | ## Introduction 24 | 25 | A UI consists of a state machine and a function that maps state to view (i.e. state => view). A UI is completely described by a set of states, the transitions between these states, and the corresponding set of views. The mathematical model of a UI illuminates UI state's global nature. UI state has broad implications and an unpredictable lifecycle, but our state management solutions are ill-fitted for such state. The Gact store combines a carefully engineered `StoreValue` and **access layer** to achieve: a **decoupled state interface**, **serializability**, **immutability**, **exact change tracking**, and **event sourcing** with practically zero boilerplate and overhead. The fusion of the aforementioned features forms the only suitable state model for UIs: an **accountable centralized state tree**. 26 | 27 | 28 | 29 | ## Other State Management Solutions 30 | 31 | 32 | 33 | ### Component State 34 | 35 | All modern UI frameworks have a notion of [**component state**](https://github.com/gactjs/gact/blob/master/docs/the-component-state-chimera.md). 36 | 37 | **Component state** is predicated on the misconception that UI state only has local implications and a predictable lifecycle. In truth, UI state has broad implications and an unpredictable lifecycle. **State hoisting**, the maneuver employed to conceal this reality, has a bevy of harmful ramifications: 38 | 39 | - Subversion of the component model 40 | - State obscurity 41 | - Transition rule disintegration 42 | - Inefficient reconciliation 43 | - Memory overhead 44 | 45 | 46 | 47 | ### Context 48 | 49 | In acknowledgement of component state's limitations, UI frameworks also have a **context** mechanism. **Context** accommodates UI state's broad implications and unpredictable lifecycle by allowing the creation of a **context tree** that provides all descendants with direct access to the **context state**. 50 | 51 | Although **context** is tailored to the nature of UI state, it is an extremely inefficient and intractable mechanism. 52 | 53 | Updating any part of **context state** triggers reconciliation of the entire **context tree**. This is a more severe case of the reconciliation inefficiency caused by **state hoisting**: a minor update with minor impact forces reconciliation of large subtrees. 54 | 55 | An instance can be a descendant of arbitrary many **context tree**s. This means that an instance can be impacted by countless containers of state all writable by potentially large subtrees of the UI. If an instance misbehaves, we have a debugging nightmare: 56 | 57 | - We have to figure out which **context**s influence the instance (an intrinsically global property) 58 | - We have to figure out if some **context state** is corrupted 59 | - If some **context state** is corrupted, we have to look through potentially huge subtrees to identify the faulty write 60 | 61 | 62 | 63 | ### Redux 64 | 65 | [Redux](https://redux.js.org/) is a **global store** architected around **event sourcing**. A **global store** perfectly accommodates UI state's broad implications and unpredictable lifecycle. **Event sourcing** is the foremost architecture for comprehendible state evolution. A **global store** supported by **event sourcing** gives rise to an **accountable centralized state tree**. 66 | 67 | Redux arises from the correct soup of concepts, but fails to implement them. **Event sourcing** requires **immutability**, **serializability**, and **state centralization**. Redux fails to provide any of these requirements: 68 | 69 | - Redux does not enforce **immutability**, and even notes that ["it is technically possible to write impure reducers that mutate the data for performance corner cases."](https://redux.js.org/introduction/prior-art/) It is possible to mutate state anywhere it is exposed such as a selector or a middleware, and any mutation compromises Redux's entire architecture. 70 | 71 | - Redux does not enforce **serializability**, It is possible to have non-serializable actions and state, and any **serializability** violation comprises Redux's entire architecture. 72 | 73 | - **State centralization** is only practical if you can create reusable components that rely on a global store. Redux lacks a **decoupled state interface**, which drastically stymies the creation of reusable code. 74 | 75 | Furthermore, Redux directly exposes the mechanics of **event sourcing**: you have to create an action, dispatch it, then process it. Consequently, Redux is criminally cumbersome. Redux defends against this criticism as follows: ["It's important that actions being objects you have to dispatch is not boilerplate, but one of the fundamental design choices of Redux...If there are no serializable plain object actions, it is impossible to record and replay user sessions, or to implement hot reloading with time travel."](https://redux.js.org/recipes/reducing-boilerplate/) Indeed, you need a serialized stream of events describing all writes to achieve **session recording** and **time-travel debugging**. However, this stream of events can be automatically created by the store. 76 | 77 | 78 | 79 | ### MobX 80 | 81 | [MobX](https://mobx.js.org/) is a state management solution that augments a mutable state model with a tracking layer. A JavaScript value is made `observable`, and can then be reactively used by an `observer`. MobX enables state tractability through [actions](https://mobx.js.org/refguide/api.html#actions), which limit mutation to specially annotated functions. 82 | 83 | The creator of MobX realized that [event sourcing can be automatically driven by the store](https://twitter.com/mweststrate/status/755820349451886592). However, **Event sourcing** requires **complete write accounting**, **serializability**, and **state centralization**. MobX fails to meet any of these requirements: 84 | 85 | - Mutable state cannot be the foundation of an **accountable state mobel**. (i.e **complete write accounting** _IMPLIES_ **immutability**). As of v5, MobX's tracking is impressively comprehensive, but still can only track property access. Consequently, mutative methods can modify state unnoticed. Additionally, MobX's tracking is provided by `Proxy`s, the underlying target can still be invisibly mutated. 86 | 87 | - MobX severely hinders **serializability** by promoting reliance on references. A serialized value cannot maintain **referential integrity** with the outside world. Even maintaining **internal referential integrity** during serialization is difficult and expensive. 88 | 89 | - Like Redux, MobX lacks a **decoupled state interface**. Consequently, MobX cannot practically facilitate **state centralization**. 90 | 91 | 92 | 93 | ## Store Value 94 | 95 | To achieve **serializability** the values in the store must be **cloneable** and **reference-agnostic**. The values must be **cloneable** because we need to recreate them during deserialization. The values need to be **reference-agnostic** because the clones will be references to different values. And while it is possible to maintain **internal referential integrity**, it is impossible to maintain **referential integrity** with the outside world. 96 | 97 | It is possible to achieve **immutability** with only the constraints imposed by **serializability**. However, **immutability** is made drastically more efficient if we can freeze values. If we can freeze values, we can share the same copy of a value with all readers as opposed to having to make a copy for each reader. Further, frozen values enable the Gact store to leverage **structural sharing**. **Structural sharing** is a technique to optimize copying by only making copies of the parts of a value affected by a write. 98 | 99 | **Cloneability** restricts us to the [cloneable built-in types](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm). 100 | 101 | **Reference-agnosticism** further reduces the type of value: 102 | 103 | - `TypedArray`s are disallowed because they are necessarily referential types 104 | - `Map` and `Set` are disallowed because they only provide substantial value when references are allowed 105 | 106 | **Freezability** adds a few more restrictions: 107 | 108 | - `Date` is disallowed because it has mutative methods 109 | - `ArrayBuffer`s are disallowed because they can be indirectly changed via `TypedArray`s 110 | 111 | Finally, there are a couple strategic restrictions: 112 | 113 | - `undefined` is disallowed to simplify state usage 114 | - `RegExp` is disallowed because there appears to be any practical use cases 115 | 116 | `StoreValue` is defined as follows: 117 | 118 | ```ts 119 | type Primitive = string | number | bigint | boolean | null; 120 | 121 | type StoreRecord = { [key: string]: StoreValue }; 122 | 123 | type StoreArray = Array; 124 | 125 | type Container = StoreArray | StoreRecord; 126 | 127 | type Complex = Container | Blob | File; 128 | 129 | type StoreValue = Primitive | Complex; 130 | ``` 131 | 132 | 133 | 134 | ## Decoupled State Interface 135 | 136 | Reusable code that relies on a global store must not encode assumptions about the state tree. To achieve this decoupling we leverage the following insight: code that interacts with the store requires specific types of state, but is agnostic to the location of this state. 137 | 138 | Gact provides a **decoupled state interface** by supplying: 139 | 140 | - a type to declare state requirements in a location-agnostic manner 141 | - an **access layer** to operate on state in a location-agnostic manner 142 | 143 | 144 | 145 | ### PathFor 146 | 147 | The `PathFor` type is at the heart of the **decoupled state interface**: 148 | 149 | ```ts 150 | // a path in S with the value V 151 | PathFor 152 | ``` 153 | 154 | Let's look at an example: 155 | 156 | ```ts 157 | type State = { 158 | a: { 159 | b: { 160 | c: number; 161 | }; 162 | d: number; 163 | }; 164 | e: bigint; 165 | }; 166 | 167 | const p1: PathFor; // ["e"] 168 | const p2: PathFor; // ["a", "d] | ["a", "b", "c"]` 169 | ``` 170 | 171 | 172 | 173 | ### Access Layer 174 | 175 | The Gact store **access layer** is comprised of the following functions: 176 | 177 | - `path`: constructs a path, which declares the value you want to operate on 178 | - `get`: reads a value from the store 179 | - `set`: sets a value in the store 180 | - `update`: updates a value in the store 181 | - `remove`: removes a value from the store 182 | - `transaction`: allows you to compose the four CRUD operations into an atomic operation 183 | 184 | Importantly, the **access layer** allows us to operate on values in a location-agnostic manner. For example, if you have the location of a `number`, you can operate on that number regardless of where it is in the state tree: 185 | 186 | ```ts 187 | function increment(store: S, countPath: PathFor) { 188 | store.update(countPath, c => c + 1); 189 | } 190 | ``` 191 | 192 | 193 | 194 | ## Immutability 195 | 196 | JavaScript objects are naturally mutable. Therefore, to provide **immutability** a store must never directly expose the objects it holds. 197 | 198 | The Gact store ensures values can only ever be written through the **access layer**: 199 | 200 | - values are cloned as they enter the store via `set` or `update` to prevent the outside world from maintaining a mutable reference to a value in the store 201 | - `get` returns frozen clones of the values held by the store 202 | 203 | By cloning values on reads and writes, we ensure that a value held by the store is never directly exposed to the outside world. Crucially, the value held by the store and the clones are **fungible** because the store enforces **reference-agnosticism**. 204 | 205 | Let's see this in action: 206 | 207 | ```ts 208 | type Person = { 209 | name: string; 210 | }; 211 | 212 | type State = { 213 | people: Array; 214 | }; 215 | 216 | let bob = { 217 | name: "bob" 218 | }; 219 | 220 | // we add bob to the store 221 | set(path("people", 0), bob); 222 | 223 | // we mutate the bob that exists in the outside world 224 | bob.name = "not bob haha"; 225 | 226 | let bobName = get(path("people", 0, "name")); 227 | 228 | console.log(`My name is ${bobName}`); // My name is bob 229 | ``` 230 | 231 | The overhead of cloning is minimal because: 232 | 233 | - the preponderance of reads and writes for a user interface involve `PrimitiveValue`s, which are intrinsically **immutable** and do not require any copying 234 | - by freezing values and leveraging **structural sharing** we ensure that we make at most one copy of any state element of a given state tree for _all readers_ 235 | 236 | 237 | 238 | ## Serializability 239 | 240 | `StoreValue` was defined to ensure and standardize **serializability**. 241 | 242 | 243 | 244 | ## Exact Change Tracking 245 | 246 | [Accounting](https://github.com/gactjs/gact/blob/master/docs/the-reactive-framework-accounting-problem.md) is a fundamental problem for all reactive systems. The Gact store provides **exact change tracking** with practically zero overhead. 247 | 248 | The **access layer** is designed around paths: 249 | 250 | ```ts 251 | const count = get(path("count")); 252 | 253 | update(path("count"), c => c + 1); 254 | ``` 255 | 256 | The path you provide to the **access layer** precisely identifies the value being operated on! This is the only information we need for **exact change tracking**: 257 | 258 | ```ts 259 | type State = { 260 | countOne: number; 261 | countTwo: number; 262 | }; 263 | 264 | // we know that FirstCount depends on ["countOne"] 265 | function FirstCount() { 266 | const count = useValue(path("countOne")); 267 | 268 | return
{count}
; 269 | } 270 | 271 | // we know that SecondCount depends on ["countTwo"] 272 | function SecondCount() { 273 | const count = useValue(path("countTwo")); 274 | 275 | return
{count}
; 276 | } 277 | 278 | // we write ["countOne"], and immediately know to update FirstCount 279 | update(path("countOne"), c => c + 1); 280 | ``` 281 | 282 | 283 | 284 | ## State Centralization 285 | 286 | UI state has broad implications and an unpredictable lifecycle. 287 | 288 | A **centralized state tree** accommodates: 289 | 290 | - state's broad implications by giving the entire UI direct access to all state 291 | - all possible lifecycles by allowing state to be created and destroyed at anytime 292 | 293 | 294 | 295 | ### Components 296 | 297 | The Gact store's **decoupled state interface** enables complete **state centralization**. 298 | 299 | This **component creation pattern** builds atop the **decoupled state interface** to define encapsulated components that rely on a global state tree. 300 | 301 | Let's create an example `Counter` component that we could release as an npm package: 302 | 303 | ```ts 304 | import React from "react"; 305 | import { Store, StoreValue, PathFor } from "@gact/store"; 306 | import { ReactStore } from "@gact/react-store"; 307 | 308 | type Props = { 309 | countPath: PathFor; 310 | } 311 | 312 | export default function createCounter({ update, useValue}: ReactStore)) { 313 | return function Counter({ countPath }: Props) { 314 | const count = useValue(countPath); 315 | 316 | function increment() { 317 | update(countPath, c => c + 1); 318 | } 319 | 320 | function decrement() { 321 | update(countPath, c => c - 1); 322 | } 323 | 324 | return ( 325 |
326 | {count} 327 | 328 | 329 |
330 | ); 331 | }; 332 | } 333 | ``` 334 | 335 | 336 | 337 | ## Event Sourcing 338 | 339 | [**Event sourcing**](https://martinfowler.com/eaaDev/EventSourcing.html) enables world-class **debuggability** and **auditability**. 340 | 341 | The Gact store supports **event sourcing** with zero boilerplate! The **access layer** let's you directly express write logic. However, behind the scenes, the **access layer** also generates and distributes events that capture all access. 342 | 343 | Let's consider a simple write: 344 | 345 | ```ts 346 | set(path("balance"), 5000); 347 | ``` 348 | 349 | The **access layer** will process the write, and generate the following event: 350 | 351 | ```ts 352 | { 353 | type: "SET", 354 | path: ["balance"], 355 | prevValue: 1000, 356 | value: 5000, 357 | meta: null 358 | } 359 | ``` 360 | 361 | Furthermore, functions in the **access layer** accept a `meta` argument which allows you to associate metadata with your access. This is a boon for **debuggability**: 362 | 363 | ```ts 364 | store.set(["balance"], 5000, "injection of funds"); 365 | ``` 366 | 367 | When processing the above `set`, the store will generate the following event: 368 | 369 | ```ts 370 | { 371 | type: "SET", 372 | path: ["balance"], 373 | prevValue: 1000, 374 | value: 5000, 375 | meta: "injection of funds" 376 | } 377 | ``` 378 | 379 | 380 | 381 | ## Conclusion 382 | 383 | Only a **centralized state tree** can accommodate UI state's broad implications and unpredictable lifecycle. The Gact store enables complete centralization through a **decoupled state interface**. However, a **naive centralized state tree** is terribly inefficient (i.e we have to rereneder the entire UI on each state update) and intractable (i.e. the entire UI is a suspect in every bug). We need an **accountable centralized state tree**: a centralized state tree supported by **exact change tracking** and **event sourcing**. Any part of the UI could read and write any part of the state tree, but in reality only reads and writes a small portion of the state tree. **Exact change tracking** illuminates actual state usage, which fosters maximally efficient reconciliation and **debuggability**. **Event sourcing** complements **change tracking** by capturing state provenance. In combination, **exact change tracking** and **event sourcing** capture the impact and evolution of state. 384 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * `ValueOf` is the analog of `keyof` for values. 3 | * It produces a union of all the values in a value. 4 | * 5 | * For example: 6 | * ```ts 7 | * type Example = { 8 | * a: number; 9 | * b: string 10 | * } 11 | * 12 | * ValueOf string | number 13 | * ``` 14 | * 15 | * @typeParam T - the objects whose values we want a union of 16 | */ 17 | type ValueOf = T[keyof T]; 18 | 19 | type MatchThen = T extends U ? V : never; 20 | 21 | /** 22 | * The primitive values allowed in the store 23 | * 24 | * @remarks 25 | * `Symbol`s are disallowed because they cannot be serialized. 26 | */ 27 | export type Primitive = string | number | bigint | boolean | null; 28 | 29 | /** 30 | * @remarks 31 | * Not defined as `Record` because TS incorrectly 32 | * throws a circular reference error 33 | */ 34 | export type StoreRecord = { [key: string]: StoreValue }; 35 | 36 | export type StoreArray = Array; 37 | 38 | /** 39 | * The containers allowed in the store. 40 | * 41 | * @remarks 42 | * The restriction to `StoreRecord` and `StoreArray` allows us to freeze 43 | * `StoreValue`s. All the other types allowed in the store are either 44 | * immutable (e.g `Primitive`) or freezable (e.g `Blob`). The ability 45 | * to freeze values allows us to implement **structural sharing**, which 46 | * makes store reads much more efficient. 47 | * 48 | * `Map`s and `Set`s are disallowed because they because they only provide 49 | * substantial value when references are allowed. Further, `Map`s and `Set`s 50 | * are unfreezable. 51 | */ 52 | export type Container = StoreRecord | StoreArray; 53 | 54 | /** 55 | * The complex values allowed in the store. 56 | * 57 | * @remarks 58 | * `Complex` means not a Primitive value. 59 | * 60 | * The requirement for **cloneability** restricts us to {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm | cloneable built-in types}. 61 | * 62 | * `TypedArray`s are disallowed because they are necessarily referential types. 63 | * 64 | * `Date` is disallowed because it has mutative methods, and cannot be frozen. 65 | * 66 | * `RegExp` is not included because there appear to practical use cases 67 | */ 68 | export type Complex = Container | Blob | File; 69 | 70 | /** 71 | * The type of value allowed in the store. 72 | * 73 | * @remarks 74 | * StoreValue was carefully engineered to guarantee **serializability** 75 | * and **immutability**. 76 | */ 77 | export type StoreValue = Primitive | Complex; 78 | 79 | /** 80 | * `ContainerKey` is the building block of paths. 81 | * 82 | * @remarks 83 | * In order to circumvent {@link https://github.com/microsoft/TypeScript/issues/31619 | type instantiation limits} `ContainerKey` 84 | * is not defined as: 85 | * ```ts 86 | * type ContainerKey = S extends Container 87 | * ? Exclude> 88 | * : never; 89 | * 90 | * The definition we use unfortunately recognizes built-in properties. We exclude a selected list of built-in properties 91 | * to reduce this leakage. However, we cannot exclude all built-in properties because that would make it impossible to 92 | * use those same property names elsewhere (e.g. a StoreRecord couldn't have a property "search" because that's a built-in method 93 | * on strings). 94 | * ``` 95 | */ 96 | export type ContainerKey = Exclude< 97 | keyof S, 98 | | "toString" 99 | | "toFixed" 100 | | "toExponential" 101 | | "toPrecision" 102 | | "valueOf" 103 | | "toLocaleString" 104 | >; 105 | 106 | export type PathFor = 107 | | MatchThen 108 | | ValueOf< 109 | { 110 | [K1 in ContainerKey]: 111 | | MatchThen 112 | | ValueOf< 113 | { 114 | [K2 in ContainerKey]: 115 | | MatchThen 116 | | ValueOf< 117 | { 118 | [K3 in ContainerKey]: 119 | | MatchThen 120 | | ValueOf< 121 | { 122 | [K4 in ContainerKey]: 123 | | MatchThen< 124 | S[K1][K2][K3][K4], 125 | V, 126 | [K1, K2, K3, K4] 127 | > 128 | | ValueOf< 129 | { 130 | [K5 in ContainerKey< 131 | S[K1][K2][K3][K4] 132 | >]: 133 | | MatchThen< 134 | S[K1][K2][K3][K4][K5], 135 | V, 136 | [K1, K2, K3, K4, K5] 137 | > 138 | | ValueOf< 139 | { 140 | [K6 in ContainerKey< 141 | S[K1][K2][K3][K4][K5] 142 | >]: MatchThen< 143 | S[K1][K2][K3][K4][K5][K6], 144 | V, 145 | [K1, K2, K3, K4, K5, K6] 146 | >; 147 | } 148 | >; 149 | } 150 | >; 151 | } 152 | >; 153 | } 154 | >; 155 | } 156 | >; 157 | } 158 | >; 159 | 160 | /** 161 | * `Path` represents all the paths in S 162 | * 163 | * @typeParam S - the state tree 164 | */ 165 | export type Path = PathFor; 166 | 167 | /** 168 | * `PathFactory` makes it easy to construct and compose paths 169 | * 170 | * @remarks 171 | * The order of the overloads is significant. The key only overloads come before 172 | * the path extension overloads for maximum completion support. 173 | * 174 | * @typeParam S - the state tree 175 | */ 176 | export type PathFactory = { 177 | (): []; 178 | 179 | >(key1: K1): [K1]; 180 | 181 | , K2 extends ContainerKey>( 182 | key1: K1, 183 | key2: K2 184 | ): [K1, K2]; 185 | 186 | , K2 extends ContainerKey>( 187 | path: [K1], 188 | key2: K2 189 | ): [K1, K2]; 190 | 191 | < 192 | K1 extends ContainerKey, 193 | K2 extends ContainerKey, 194 | K3 extends ContainerKey 195 | >( 196 | key1: K1, 197 | key2: K2, 198 | key3: K3 199 | ): [K1, K2, K3]; 200 | 201 | < 202 | K1 extends ContainerKey, 203 | K2 extends ContainerKey, 204 | K3 extends ContainerKey 205 | >( 206 | path: [K1], 207 | key2: K2, 208 | key3: K3 209 | ): [K1, K2, K3]; 210 | 211 | < 212 | K1 extends ContainerKey, 213 | K2 extends ContainerKey, 214 | K3 extends ContainerKey 215 | >( 216 | path: [K1, K2], 217 | key3: K3 218 | ): [K1, K2, K3]; 219 | 220 | < 221 | K1 extends ContainerKey, 222 | K2 extends ContainerKey, 223 | K3 extends ContainerKey, 224 | K4 extends ContainerKey 225 | >( 226 | key1: K1, 227 | key2: K2, 228 | key3: K3, 229 | key4: K4 230 | ): [K1, K2, K3, K4]; 231 | 232 | < 233 | K1 extends ContainerKey, 234 | K2 extends ContainerKey, 235 | K3 extends ContainerKey, 236 | K4 extends ContainerKey 237 | >( 238 | path: [K1], 239 | key2: K2, 240 | key3: K3, 241 | key4: K4 242 | ): [K1, K2, K3, K4]; 243 | 244 | < 245 | K1 extends ContainerKey, 246 | K2 extends ContainerKey, 247 | K3 extends ContainerKey, 248 | K4 extends ContainerKey 249 | >( 250 | path: [K1, K2], 251 | key3: K3, 252 | key4: K4 253 | ): [K1, K2, K3, K4]; 254 | 255 | < 256 | K1 extends ContainerKey, 257 | K2 extends ContainerKey, 258 | K3 extends ContainerKey, 259 | K4 extends ContainerKey 260 | >( 261 | path: [K1, K2, K3], 262 | key4: K4 263 | ): [K1, K2, K3, K4]; 264 | 265 | < 266 | K1 extends ContainerKey, 267 | K2 extends ContainerKey, 268 | K3 extends ContainerKey, 269 | K4 extends ContainerKey, 270 | K5 extends ContainerKey 271 | >( 272 | key1: K1, 273 | key2: K2, 274 | key3: K3, 275 | key4: K4, 276 | key5: K5 277 | ): [K1, K2, K3, K4, K5]; 278 | 279 | < 280 | K1 extends ContainerKey, 281 | K2 extends ContainerKey, 282 | K3 extends ContainerKey, 283 | K4 extends ContainerKey, 284 | K5 extends ContainerKey 285 | >( 286 | path: [K1], 287 | key2: K2, 288 | key3: K3, 289 | key4: K4, 290 | key5: K5 291 | ): [K1, K2, K3, K4, K5]; 292 | 293 | < 294 | K1 extends ContainerKey, 295 | K2 extends ContainerKey, 296 | K3 extends ContainerKey, 297 | K4 extends ContainerKey, 298 | K5 extends ContainerKey 299 | >( 300 | path: [K1, K2], 301 | key3: K3, 302 | key4: K4, 303 | key5: K5 304 | ): [K1, K2, K3, K4, K5]; 305 | 306 | < 307 | K1 extends ContainerKey, 308 | K2 extends ContainerKey, 309 | K3 extends ContainerKey, 310 | K4 extends ContainerKey, 311 | K5 extends ContainerKey 312 | >( 313 | path: [K1, K2, K3], 314 | key4: K4, 315 | key5: K5 316 | ): [K1, K2, K3, K4, K5]; 317 | 318 | < 319 | K1 extends ContainerKey, 320 | K2 extends ContainerKey, 321 | K3 extends ContainerKey, 322 | K4 extends ContainerKey, 323 | K5 extends ContainerKey 324 | >( 325 | path: [K1, K2, K3, K4], 326 | key5: K5 327 | ): [K1, K2, K3, K4, K5]; 328 | 329 | >( 330 | path: PathFor, 331 | key1: K1 332 | ): V[K1] extends StoreValue ? PathFor : never; 333 | 334 | < 335 | K1 extends ContainerKey, 336 | K2 extends ContainerKey, 337 | K3 extends ContainerKey, 338 | K4 extends ContainerKey, 339 | K5 extends ContainerKey, 340 | K6 extends ContainerKey 341 | >( 342 | key1: K1, 343 | key2: K2, 344 | key3: K3, 345 | key4: K4, 346 | key5: K5, 347 | key6: K6 348 | ): [K1, K2, K3, K4, K5, K6]; 349 | 350 | < 351 | K1 extends ContainerKey, 352 | K2 extends ContainerKey, 353 | K3 extends ContainerKey, 354 | K4 extends ContainerKey, 355 | K5 extends ContainerKey, 356 | K6 extends ContainerKey 357 | >( 358 | path: [K1], 359 | key2: K2, 360 | key3: K3, 361 | key4: K4, 362 | key5: K5, 363 | key6: K6 364 | ): [K1, K2, K3, K4, K5, K6]; 365 | 366 | < 367 | K1 extends ContainerKey, 368 | K2 extends ContainerKey, 369 | K3 extends ContainerKey, 370 | K4 extends ContainerKey, 371 | K5 extends ContainerKey, 372 | K6 extends ContainerKey 373 | >( 374 | path: [K1, K2], 375 | key3: K3, 376 | key4: K4, 377 | key5: K5, 378 | key6: K6 379 | ): [K1, K2, K3, K4, K5, K6]; 380 | 381 | < 382 | K1 extends ContainerKey, 383 | K2 extends ContainerKey, 384 | K3 extends ContainerKey, 385 | K4 extends ContainerKey, 386 | K5 extends ContainerKey, 387 | K6 extends ContainerKey 388 | >( 389 | path: [K1, K2, K3], 390 | key4: K4, 391 | key5: K5, 392 | key6: K6 393 | ): [K1, K2, K3, K4, K5, K6]; 394 | 395 | < 396 | K1 extends ContainerKey, 397 | K2 extends ContainerKey, 398 | K3 extends ContainerKey, 399 | K4 extends ContainerKey, 400 | K5 extends ContainerKey, 401 | K6 extends ContainerKey 402 | >( 403 | path: [K1, K2, K3, K4], 404 | key5: K5, 405 | key6: K6 406 | ): [K1, K2, K3, K4, K5, K6]; 407 | 408 | < 409 | K1 extends ContainerKey, 410 | K2 extends ContainerKey, 411 | K3 extends ContainerKey, 412 | K4 extends ContainerKey, 413 | K5 extends ContainerKey, 414 | K6 extends ContainerKey 415 | >( 416 | path: [K1, K2, K3, K4, K5], 417 | key6: K6 418 | ): [K1, K2, K3, K4, K5, K6]; 419 | 420 | >( 421 | path: PathFor, 422 | key1: K1 423 | ): V[K1] extends StoreValue ? PathFor : never; 424 | 425 | < 426 | V extends StoreValue, 427 | K1 extends ContainerKey, 428 | K2 extends ContainerKey 429 | >( 430 | path: PathFor, 431 | key1: K1, 432 | key2: K2 433 | ): V[K1][K2] extends StoreValue ? PathFor : never; 434 | 435 | < 436 | V extends StoreValue, 437 | K1 extends ContainerKey, 438 | K2 extends ContainerKey, 439 | K3 extends ContainerKey 440 | >( 441 | path: PathFor, 442 | key1: K1, 443 | key2: K2, 444 | key3: K3 445 | ): V[K1][K2][K3] extends StoreValue ? PathFor : never; 446 | 447 | < 448 | V extends StoreValue, 449 | K1 extends ContainerKey, 450 | K2 extends ContainerKey, 451 | K3 extends ContainerKey, 452 | K4 extends ContainerKey 453 | >( 454 | path: PathFor, 455 | key1: K1, 456 | key2: K2, 457 | key3: K3, 458 | key4: K4 459 | ): V[K1][K2][K3][K4] extends StoreValue 460 | ? PathFor 461 | : never; 462 | 463 | < 464 | V extends StoreValue, 465 | K1 extends ContainerKey, 466 | K2 extends ContainerKey, 467 | K3 extends ContainerKey, 468 | K4 extends ContainerKey, 469 | K5 extends ContainerKey 470 | >( 471 | path: PathFor, 472 | key1: K1, 473 | key2: K2, 474 | key3: K3, 475 | key4: K4, 476 | key5: K5 477 | ): V[K1][K2][K3][K4][K5] extends StoreValue 478 | ? PathFor 479 | : never; 480 | 481 |

>(path: P): P; 482 | }; 483 | 484 | /** 485 | * `PathFactoryResult` pairs a pathFactory with `fromFactory`, which 486 | * allows us to determine whether a given path was produced by the 487 | * pathFactory 488 | */ 489 | export type PathFactoryResult = { 490 | path: PathFactory; 491 | fromFactory(path: Path): boolean; 492 | }; 493 | 494 | /** 495 | * Enables efficient immutable reads through structural sharing and deep freezing 496 | * 497 | * @typeParam S - the state tree 498 | */ 499 | export type ReadManager = { 500 | reset(): void; 501 | reconcile(paths: Set>): void; 502 | clone(path: PathFor, value: V): V; 503 | }; 504 | 505 | export enum EventType { 506 | Init = "INIT", 507 | Get = "GET", 508 | Set = "SET", 509 | Update = "UPDATE", 510 | Remove = "REMOVE", 511 | Transaction = "TRANSACTION" 512 | } 513 | 514 | /** 515 | * `Updater`s specify an update to a value in the store. 516 | * 517 | * If an `Updater` returns a value, then we use that as the updated value. 518 | * 519 | * If an `Updater` does not return a value, then we assume the inputted value 520 | * has been mutated, and use that as the updated value. 521 | * 522 | * @typeParam T - the type of value we are updating. 523 | */ 524 | export type Updater = (value: T) => T | void; 525 | 526 | /** 527 | * `InitEvent` captures the initialization of the store. 528 | * 529 | * @remarks 530 | * `InitEvent` is completely frozen (i.e immutable). 531 | * 532 | * @typeParam S - the state tree 533 | */ 534 | export type InitEvent = { 535 | type: EventType.Init; 536 | state: S; 537 | }; 538 | 539 | /** 540 | * `GetEvent` captures getting a value from the store. 541 | * 542 | * @remarks 543 | * `GetEvent` is completely frozen (i.e. immutable). 544 | */ 545 | export type GetEvent = { 546 | type: EventType.Get; 547 | path: PathFor; 548 | value: V; 549 | meta: StoreRecord | null; 550 | }; 551 | 552 | /** 553 | * `SetEvent` captures setting a value in the store. 554 | * 555 | * @remarks 556 | * `SetEvent` is completely frozen (i.e. immutable). 557 | */ 558 | export type SetEvent = { 559 | type: EventType.Set; 560 | path: PathFor; 561 | prevValue: V | null; 562 | value: V; 563 | meta: StoreRecord | null; 564 | }; 565 | 566 | /** 567 | * `UpdateEvent` captures updating a value in the store. 568 | * 569 | * @remarks 570 | * `UpdateEvent` is completely frozen (i.e. immutable). 571 | */ 572 | export type UpdateEvent = { 573 | type: EventType.Update; 574 | path: PathFor; 575 | prevValue: V; 576 | value: V; 577 | meta: StoreRecord | null; 578 | }; 579 | 580 | /** 581 | * `RemoveEvent` captures deleting a value in the store. 582 | * 583 | * @remarks 584 | * `RemoveEvent` is completely frozen (i.e. immutable). 585 | */ 586 | export type RemoveEvent = { 587 | type: EventType.Remove; 588 | path: PathFor; 589 | prevValue: V; 590 | meta: StoreRecord | null; 591 | }; 592 | 593 | /** 594 | * `WriteEvent`s capture writes to the store. 595 | * 596 | * @remarks 597 | * `WriteEvent`s are completely frozen (i.e. immutable). 598 | */ 599 | export type WriteEvent = 600 | | SetEvent 601 | | UpdateEvent 602 | | RemoveEvent; 603 | 604 | /** 605 | * `CrudEvent`s capture read and writes to the store. 606 | * 607 | * @remarks 608 | * `CrudEvent`s are completely frozen (i.e. immutable). 609 | */ 610 | export type CRUDEvent< 611 | S extends StoreValue, 612 | V extends StoreValue = StoreValue 613 | > = GetEvent | WriteEvent; 614 | 615 | /** 616 | * `TransactionEvent` captures a transaction. 617 | * 618 | * @remarks 619 | * `TransactionEvent` is completely frozen (i.e. immutable). 620 | */ 621 | export type TransactionEvent = { 622 | type: EventType.Transaction; 623 | events: Array>; 624 | meta: StoreRecord | null; 625 | }; 626 | 627 | /** 628 | * `StoreEvent`s capture all store activity. 629 | * 630 | * @remarks 631 | * `StoreEvent`s are completely frozen (i.e. immutable). 632 | */ 633 | export type StoreEvent< 634 | S extends StoreValue, 635 | V extends StoreValue = StoreValue 636 | > = InitEvent | CRUDEvent | TransactionEvent; 637 | 638 | /** 639 | * `Listener`s subscribe to the stream of `StoreEvent`s. 640 | * 641 | * @remarks 642 | * It is common for a `Listener` to want to perform operations on the store 643 | * that will generate further events. In order to avoid infinite loops, it is 644 | * recommended that the `meta` argument be used to identify activity originating 645 | * from the subscriber. 646 | * 647 | * @typeParam S - the state tree 648 | */ 649 | export type Listener = (event: StoreEvent) => void; 650 | 651 | /** 652 | * `Store` implements an **accountable centralized state tree**. 653 | * 654 | * @typeParam S - the state tree 655 | */ 656 | export type Store = { 657 | subscribe(listener: Listener): () => void; 658 | 659 | canMutateSubscriptions(): boolean; 660 | 661 | path: PathFactory; 662 | 663 | get(path: PathFor, meta?: StoreValue): V; 664 | 665 | set( 666 | path: PathFor, 667 | value: V, 668 | meta?: StoreValue 669 | ): void; 670 | 671 | update( 672 | path: PathFor, 673 | updater: Updater, 674 | meta?: StoreValue 675 | ): void; 676 | 677 | remove(path: PathFor, meta?: StoreValue): void; 678 | 679 | transaction(transaction: () => void, meta?: StoreValue): void; 680 | }; 681 | -------------------------------------------------------------------------------- /__tests__/createStore.spec.ts: -------------------------------------------------------------------------------- 1 | import { createStore, EventType } from "../src"; 2 | 3 | describe("createStore", function() { 4 | type State = { 5 | a: string; 6 | b: { 7 | c: bigint; 8 | }; 9 | d: Array; 10 | e: Record; 11 | }; 12 | 13 | const initialState: State = { 14 | a: "a", 15 | b: { 16 | c: BigInt(0) 17 | }, 18 | d: [], 19 | e: {} 20 | }; 21 | 22 | const fullInitialState: State = { 23 | a: "a", 24 | b: { 25 | c: BigInt(1000) 26 | }, 27 | d: [0, 1, 2], 28 | e: { bob: "cool", jane: "cool" } 29 | }; 30 | 31 | test("createStore", function() { 32 | expect(function() { 33 | createStore(initialState); 34 | }).not.toThrow(); 35 | }); 36 | 37 | test("store is frozen", function() { 38 | const store = createStore(initialState); 39 | 40 | expect(function() { 41 | store.get = jest.fn(); 42 | }).toThrow(); 43 | }); 44 | 45 | describe("get", function() { 46 | const { get, path } = createStore(fullInitialState); 47 | 48 | test("root", function() { 49 | expect(get(path())).toStrictEqual(fullInitialState); 50 | }); 51 | 52 | test("scalar", function() { 53 | expect(get(path("a"))).toBe("a"); 54 | }); 55 | 56 | test("deep scalar", function() { 57 | expect(get(path("b", "c"))).toBe(BigInt(1000)); 58 | 59 | expect(get(path("d", 0))).toBe(0); 60 | 61 | expect(get(path("e", "bob"))).toBe("cool"); 62 | expect(get(path("e", "jane"))).toBe("cool"); 63 | }); 64 | 65 | test("value immutability", function() { 66 | const d = get(path("d")); 67 | expect(function() { 68 | d[0] = 1; 69 | }).toThrow(); 70 | 71 | const e = get>(path("e")); 72 | expect(function() { 73 | e.bob = "not cool"; 74 | }).toThrow(); 75 | }); 76 | 77 | test("trying to get with a path not from store.path throws", function() { 78 | expect(function() { 79 | get(["a"]); 80 | }).toThrow(); 81 | }); 82 | 83 | test("trying to get a nonexistent value throws", function() { 84 | expect(function() { 85 | get(path("e", "rob")); 86 | }).toThrowError("does not exist"); 87 | }); 88 | }); 89 | 90 | describe("set", function() { 91 | test("root", function() { 92 | const { path, get, set } = createStore(initialState); 93 | 94 | set(path(), fullInitialState); 95 | expect(get(path())).toStrictEqual(fullInitialState); 96 | }); 97 | 98 | test("scalar", function() { 99 | const { path, get, set } = createStore(initialState); 100 | 101 | set(path("a"), "b"); 102 | expect(get(path("a"))).toBe("b"); 103 | }); 104 | 105 | test("deep scalar", function() { 106 | const { path, get, set } = createStore(initialState); 107 | 108 | set(path("d", 0), 1); 109 | expect(get(path("d", 0))).toBe(1); 110 | 111 | set(path("b", "c"), BigInt(1000)); 112 | expect(get(path("b", "c"))).toBe(BigInt(1000)); 113 | }); 114 | 115 | test("set value immutability", function() { 116 | const { path, set, get } = createStore(initialState); 117 | 118 | const newB = { 119 | c: BigInt(1000) 120 | }; 121 | 122 | set(path("b"), newB); 123 | 124 | newB.c = BigInt(5000); 125 | 126 | expect(get(path("b", "c"))).toBe(BigInt(1000)); 127 | }); 128 | 129 | test("trying to set with a path not from store.path throws", function() { 130 | const { set } = createStore(initialState); 131 | expect(function() { 132 | set(["a"], "b"); 133 | }).toThrow(); 134 | }); 135 | }); 136 | 137 | describe("update", function() { 138 | test("replace root", function() { 139 | const { path, get, update } = createStore(initialState); 140 | 141 | update(path(), () => fullInitialState); 142 | 143 | expect(get(path())).toStrictEqual(fullInitialState); 144 | }); 145 | 146 | test("mutate root", function() { 147 | const { path, get, update } = createStore(initialState); 148 | 149 | const newA = "aa"; 150 | const newB = { 151 | c: BigInt(1000) 152 | }; 153 | const newD = [0, 1, 2]; 154 | const newE = { bob: "cool", jane: "cool" }; 155 | 156 | update(path(), function(newState) { 157 | newState.a = newA; 158 | newState.b = newB; 159 | newState.d = newD; 160 | newState.e = newE; 161 | }); 162 | 163 | expect(get(path("a"))).toBe(newA); 164 | expect(get(path("b"))).toStrictEqual(newB); 165 | expect(get(path("d"))).toStrictEqual(newD); 166 | expect(get(path("e"))).toStrictEqual(newE); 167 | }); 168 | 169 | test("scalar", function() { 170 | const { path, get, update } = createStore(initialState); 171 | 172 | update(path("a"), a => a + "a"); 173 | expect(get(path("a"))).toBe("aa"); 174 | }); 175 | 176 | test("deep", function() { 177 | const { path, get, update } = createStore(initialState); 178 | 179 | update(path("b", "c"), d => d + BigInt(1000)); 180 | expect(get(path("b", "c"))).toBe(BigInt(1000)); 181 | }); 182 | 183 | test("updates through mutation work", function() { 184 | const { path, get, update } = createStore(initialState); 185 | update>(path("d"), function(d) { 186 | d.push(0); 187 | }); 188 | 189 | expect(get(path("d", 0))).toBe(0); 190 | }); 191 | 192 | test("prevents mutation through the input to updater", function() { 193 | const { path, get, update } = createStore(initialState); 194 | const oldD = get(path("d")); 195 | let newD: Array = []; 196 | update>(path("d"), function(d) { 197 | newD = d; 198 | }); 199 | 200 | newD.push(0); 201 | 202 | expect(get(path("d"))).toStrictEqual(oldD); 203 | }); 204 | 205 | test("updated value immutability", function() { 206 | const { path, get, update } = createStore(initialState); 207 | const newD = [0, 1, 2]; 208 | 209 | update(path("d"), () => newD); 210 | 211 | // mutate external value 212 | newD.push(3); 213 | 214 | const expectedD = [0, 1, 2]; 215 | expect(get(path("d"))).toStrictEqual(expectedD); 216 | }); 217 | 218 | test("attempting another write during an update throws", function() { 219 | const { path, set, update } = createStore(initialState); 220 | 221 | expect(function() { 222 | update(path("a"), function(a) { 223 | set(path("b", "c"), BigInt(1000)); 224 | return a + "a"; 225 | }); 226 | }).toThrow("cannot include other writes"); 227 | }); 228 | 229 | test("updating a nonexistent value throws", function() { 230 | const { path, update } = createStore(initialState); 231 | 232 | expect(function() { 233 | update(path("d", 0), n => n + 1000); 234 | }).toThrowError("does not exist"); 235 | }); 236 | 237 | test("trying to update with a path not from store.path throws", function() { 238 | const { update } = createStore(initialState); 239 | expect(function() { 240 | update(["a"], a => a + "a"); 241 | }).toThrow(); 242 | }); 243 | }); 244 | 245 | describe("remove", function() { 246 | test("cannot remove the root", function() { 247 | const { path, remove } = createStore(fullInitialState); 248 | 249 | expect(function() { 250 | remove(path()); 251 | }).toThrowError("must be called with path.length >= 1"); 252 | }); 253 | 254 | test("deletes items are removed", function() { 255 | const { path, get, remove } = createStore(fullInitialState); 256 | remove(path("d", 2)); 257 | 258 | expect(function() { 259 | get(path("d", 2)); 260 | }).toThrow(); 261 | }); 262 | 263 | test("deleting a nonexistent value throws", function() { 264 | const { path, remove } = createStore(initialState); 265 | 266 | expect(function() { 267 | remove(path("d", 3)); 268 | }).toThrowError("does not exist"); 269 | }); 270 | }); 271 | 272 | describe("transaction", function() { 273 | test("processes multiple writes", function() { 274 | const { path, get, set, update, remove, transaction } = createStore( 275 | fullInitialState 276 | ); 277 | 278 | transaction(function() { 279 | set(path("a"), "aa"); 280 | 281 | update>(path("d"), function(d) { 282 | d.push(3); 283 | }); 284 | 285 | remove(path("e", "bob")); 286 | }); 287 | 288 | expect(get(path("a"))).toBe("aa"); 289 | expect(get(path("d"))).toStrictEqual([0, 1, 2, 3]); 290 | expect(function() { 291 | get(path("e", "bob")); 292 | }).toThrow(); 293 | }); 294 | 295 | test("write atomicity", function() { 296 | const { path, get, set, update, transaction } = createStore(initialState); 297 | 298 | transaction(function() { 299 | set(path("b", "c"), BigInt(1)); 300 | // this will only be true if the previous set is not handled atomically 301 | if (get(path("b", "c"))) { 302 | update(path("b", "c"), c => c + BigInt(1)); 303 | } 304 | }); 305 | 306 | expect(get(path("b", "c"))).toBe(BigInt(1)); 307 | }); 308 | 309 | test("attempting to run a transaction during an update throws", function() { 310 | const { path, update, transaction } = createStore(initialState); 311 | 312 | expect(function() { 313 | update(path("a"), function(a) { 314 | // eslint-disable-next-line @typescript-eslint/no-empty-function 315 | transaction(function() {}); 316 | return a + "a"; 317 | }); 318 | }).toThrow(); 319 | }); 320 | 321 | test("trying to run two transaction simultaneously throws", function() { 322 | const { transaction } = createStore(initialState); 323 | 324 | expect(function() { 325 | transaction(function() { 326 | // eslint-disable-next-line @typescript-eslint/no-empty-function 327 | transaction(function() {}); 328 | }); 329 | }).toThrowError("one transaction can run at a time"); 330 | }); 331 | }); 332 | 333 | describe("subscribe", function() { 334 | test("subscriber receives event stream", function() { 335 | const { 336 | path, 337 | get, 338 | set, 339 | update, 340 | remove, 341 | transaction, 342 | subscribe 343 | } = createStore(fullInitialState); 344 | const subscriber = jest.fn(); 345 | subscribe(subscriber); 346 | 347 | get(path()); 348 | set(path(), fullInitialState); 349 | // eslint-disable-next-line @typescript-eslint/no-empty-function 350 | update(path(), function() {}); 351 | remove(path("e", "bob")); 352 | // eslint-disable-next-line @typescript-eslint/no-empty-function 353 | transaction(function() {}); 354 | 355 | // init, get, set, update, remove, transaction 356 | expect(subscriber).toHaveBeenCalledTimes(6); 357 | }); 358 | 359 | test("subscriber stops receiving requests after unsubscribing", function() { 360 | const { 361 | path, 362 | get, 363 | set, 364 | update, 365 | remove, 366 | transaction, 367 | subscribe 368 | } = createStore(fullInitialState); 369 | const subscriber = jest.fn(); 370 | const unsubscribe = subscribe(subscriber); 371 | unsubscribe(); 372 | 373 | get(path()); 374 | set(path(), fullInitialState); 375 | // eslint-disable-next-line @typescript-eslint/no-empty-function 376 | update(path(), function() {}); 377 | remove(path("e", "bob")); 378 | // eslint-disable-next-line @typescript-eslint/no-empty-function 379 | transaction(function() {}); 380 | 381 | expect(subscriber.mock.calls.length).toBe(0); 382 | }); 383 | 384 | test("trying to subscribe during an update throws", function() { 385 | const { path, update, subscribe } = createStore(initialState); 386 | 387 | expect(function() { 388 | update(path(), function() { 389 | // eslint-disable-next-line @typescript-eslint/no-empty-function 390 | subscribe(function() {}); 391 | }); 392 | }).toThrowError("Cannot subscribe during an update or transaction"); 393 | }); 394 | 395 | test("trying to subscribe during a transaction throws", function() { 396 | const { transaction, subscribe } = createStore(initialState); 397 | 398 | expect(function() { 399 | transaction(function() { 400 | // eslint-disable-next-line @typescript-eslint/no-empty-function 401 | subscribe(function() {}); 402 | }); 403 | }).toThrowError("Cannot subscribe during an update or transaction"); 404 | }); 405 | 406 | test("trying to unsubscribe during an update throws", function() { 407 | const { path, update, subscribe } = createStore(initialState); 408 | 409 | expect(function() { 410 | // eslint-disable-next-line @typescript-eslint/no-empty-function 411 | const unsubscribe = subscribe(function() {}); 412 | update(path(), function() { 413 | unsubscribe(); 414 | }); 415 | }).toThrowError("Cannot unsubscribe during an update or transaction"); 416 | }); 417 | 418 | test("trying to unsubscribe during a transaction throws", function() { 419 | const { transaction, subscribe } = createStore(initialState); 420 | 421 | expect(function() { 422 | // eslint-disable-next-line @typescript-eslint/no-empty-function 423 | const unsubscribe = subscribe(function() {}); 424 | transaction(function() { 425 | unsubscribe(); 426 | }); 427 | }).toThrowError("Cannot unsubscribe during an update or transaction"); 428 | }); 429 | }); 430 | 431 | describe("canMutateSubscriptions", function() { 432 | const { canMutateSubscriptions } = createStore(initialState); 433 | 434 | test("returns true if in the middle of an update or transaction", function() { 435 | expect(canMutateSubscriptions()).toBe(true); 436 | }); 437 | 438 | test("returns false if in the middle of an update", function() { 439 | const { path, update, canMutateSubscriptions } = createStore( 440 | initialState 441 | ); 442 | update(path(), function() { 443 | expect(canMutateSubscriptions()).toBe(false); 444 | }); 445 | }); 446 | 447 | test("returns false if in the middle of a transaction", function() { 448 | const { transaction, canMutateSubscriptions } = createStore(initialState); 449 | transaction(function() { 450 | expect(canMutateSubscriptions()).toBe(false); 451 | }); 452 | }); 453 | }); 454 | 455 | describe("event stream", function() { 456 | const testMeta = { 457 | test: true 458 | }; 459 | 460 | test("event are immutable", function() { 461 | const { 462 | path, 463 | get, 464 | set, 465 | update, 466 | remove, 467 | transaction, 468 | subscribe 469 | } = createStore(initialState); 470 | const subscriber = jest.fn(); 471 | subscribe(subscriber); 472 | 473 | get(path()); 474 | set(path(), fullInitialState); 475 | // eslint-disable-next-line @typescript-eslint/no-empty-function 476 | update(path(), function() {}); 477 | remove(path("e", "bob")); 478 | // eslint-disable-next-line @typescript-eslint/no-empty-function 479 | transaction(function() {}); 480 | 481 | const initEvent = subscriber.mock.calls[0][0]; 482 | const getEvent = subscriber.mock.calls[0][0]; 483 | const setEvent = subscriber.mock.calls[0][0]; 484 | const updateEvent = subscriber.mock.calls[0][0]; 485 | const RemoveEvent = subscriber.mock.calls[0][0]; 486 | const transactionEvent = subscriber.mock.calls[0][0]; 487 | 488 | expect(function() { 489 | initEvent.extra = true; 490 | }).toThrow; 491 | 492 | expect(function() { 493 | getEvent.extra = true; 494 | }).toThrow; 495 | 496 | expect(function() { 497 | setEvent.extra = true; 498 | }).toThrow; 499 | 500 | expect(function() { 501 | updateEvent.extra = true; 502 | }).toThrow; 503 | 504 | expect(function() { 505 | RemoveEvent.extra = true; 506 | }).toThrow; 507 | 508 | expect(function() { 509 | transactionEvent.extra = true; 510 | }).toThrow; 511 | }); 512 | 513 | test("emits expected init event", function() { 514 | const { path, get, subscribe } = createStore(initialState); 515 | const subscriber = jest.fn(); 516 | subscribe(subscriber); 517 | get(path()); 518 | const receivedInitEvent = subscriber.mock.calls[0][0]; 519 | const expectedInitEvent = { 520 | state: initialState, 521 | type: EventType.Init 522 | }; 523 | 524 | expect(receivedInitEvent).toStrictEqual(expectedInitEvent); 525 | }); 526 | 527 | test("emits expected get event", function() { 528 | const { path, get, subscribe } = createStore(initialState); 529 | const subscriber = jest.fn(); 530 | subscribe(subscriber); 531 | get(path()); 532 | const receivedGetEvent = subscriber.mock.calls[1][0]; 533 | const expectedInitEvent = { 534 | meta: null, 535 | path: [], 536 | type: EventType.Get, 537 | value: initialState 538 | }; 539 | 540 | expect(receivedGetEvent).toStrictEqual(expectedInitEvent); 541 | }); 542 | 543 | test("emits expected get event with provided meta", function() { 544 | const { path, get, subscribe } = createStore(initialState); 545 | const subscriber = jest.fn(); 546 | subscribe(subscriber); 547 | get(path(), testMeta); 548 | const receivedGetEvent = subscriber.mock.calls[1][0]; 549 | const expectedInitEvent = { 550 | meta: testMeta, 551 | path: [], 552 | type: EventType.Get, 553 | value: initialState 554 | }; 555 | 556 | expect(receivedGetEvent).toStrictEqual(expectedInitEvent); 557 | }); 558 | 559 | test("emits expected set event", function() { 560 | const { path, set, subscribe } = createStore(initialState); 561 | const subscriber = jest.fn(); 562 | subscribe(subscriber); 563 | set(path(), fullInitialState); 564 | const receivedSetEvent = subscriber.mock.calls[1][0]; 565 | const expectedSetEvent = { 566 | meta: null, 567 | path: [], 568 | prevValue: initialState, 569 | type: EventType.Set, 570 | value: fullInitialState 571 | }; 572 | 573 | expect(receivedSetEvent).toStrictEqual(expectedSetEvent); 574 | }); 575 | 576 | test("emits expected set event with provided meta", function() { 577 | const { path, set, subscribe } = createStore(initialState); 578 | const subscriber = jest.fn(); 579 | subscribe(subscriber); 580 | set(path(), fullInitialState, testMeta); 581 | const receivedSetEvent = subscriber.mock.calls[1][0]; 582 | const expectedSetEvent = { 583 | meta: testMeta, 584 | path: [], 585 | prevValue: initialState, 586 | type: EventType.Set, 587 | value: fullInitialState 588 | }; 589 | 590 | expect(receivedSetEvent).toStrictEqual(expectedSetEvent); 591 | }); 592 | 593 | test("emits expected update event", function() { 594 | const { path, update, subscribe } = createStore(initialState); 595 | const subscriber = jest.fn(); 596 | subscribe(subscriber); 597 | update(path(), () => fullInitialState); 598 | const receivedUpdateEvent = subscriber.mock.calls[1][0]; 599 | const expectedUpdateEvent = { 600 | meta: null, 601 | path: [], 602 | prevValue: initialState, 603 | type: EventType.Update, 604 | value: fullInitialState 605 | }; 606 | 607 | expect(receivedUpdateEvent).toStrictEqual(expectedUpdateEvent); 608 | }); 609 | 610 | test("emits expected update event with provided meta", function() { 611 | const { path, update, subscribe } = createStore(initialState); 612 | const subscriber = jest.fn(); 613 | subscribe(subscriber); 614 | update(path(), () => fullInitialState, testMeta); 615 | const receivedUpdateEvent = subscriber.mock.calls[1][0]; 616 | const expectedUpdateEvent = { 617 | meta: testMeta, 618 | path: [], 619 | prevValue: initialState, 620 | type: EventType.Update, 621 | value: fullInitialState 622 | }; 623 | 624 | expect(receivedUpdateEvent).toStrictEqual(expectedUpdateEvent); 625 | }); 626 | 627 | test("emits expected remove event", function() { 628 | const { path, remove, subscribe } = createStore(fullInitialState); 629 | const subscriber = jest.fn(); 630 | subscribe(subscriber); 631 | remove(path("e", "bob")); 632 | const receivedRemoveEvent = subscriber.mock.calls[1][0]; 633 | const expectedRemoveEvent = { 634 | meta: null, 635 | path: ["e", "bob"], 636 | prevValue: "cool", 637 | type: EventType.Remove 638 | }; 639 | 640 | expect(receivedRemoveEvent).toStrictEqual(expectedRemoveEvent); 641 | }); 642 | 643 | test("emits expected remove event with provided meta", function() { 644 | const { path, remove, subscribe } = createStore(fullInitialState); 645 | const subscriber = jest.fn(); 646 | subscribe(subscriber); 647 | remove(path("e", "bob"), testMeta); 648 | const receivedRemoveEvent = subscriber.mock.calls[1][0]; 649 | const expectedRemoveEvent = { 650 | meta: testMeta, 651 | path: ["e", "bob"], 652 | prevValue: "cool", 653 | type: EventType.Remove 654 | }; 655 | 656 | expect(receivedRemoveEvent).toStrictEqual(expectedRemoveEvent); 657 | }); 658 | 659 | test("emits expected transaction event", function() { 660 | const { 661 | path, 662 | get, 663 | set, 664 | update, 665 | remove, 666 | transaction, 667 | subscribe 668 | } = createStore(fullInitialState); 669 | const subscriber = jest.fn(); 670 | subscribe(subscriber); 671 | transaction(function() { 672 | set(path("a"), "aa"); 673 | 674 | update>(path("d"), function(b) { 675 | b.push(3); 676 | }); 677 | 678 | if (get>(path("d")).length > 1) { 679 | remove(path("e", "bob")); 680 | } 681 | }); 682 | const receivedTransactionEvent = subscriber.mock.calls[1][0]; 683 | const expectedTransactionEvent = { 684 | events: [ 685 | { meta: null, path: ["d"], type: EventType.Get, value: [0, 1, 2] }, 686 | { 687 | meta: null, 688 | path: ["a"], 689 | prevValue: "a", 690 | type: EventType.Set, 691 | value: "aa" 692 | }, 693 | { 694 | meta: null, 695 | path: ["d"], 696 | prevValue: [0, 1, 2], 697 | type: EventType.Update, 698 | value: [0, 1, 2, 3] 699 | }, 700 | { 701 | meta: null, 702 | path: ["e", "bob"], 703 | prevValue: "cool", 704 | type: EventType.Remove 705 | } 706 | ], 707 | meta: null, 708 | type: "TRANSACTION" 709 | }; 710 | 711 | expect(receivedTransactionEvent).toStrictEqual(expectedTransactionEvent); 712 | }); 713 | 714 | test("emits expected transaction event with provided meta", function() { 715 | const { 716 | path, 717 | get, 718 | set, 719 | update, 720 | remove, 721 | transaction, 722 | subscribe 723 | } = createStore(fullInitialState); 724 | const subscriber = jest.fn(); 725 | subscribe(subscriber); 726 | transaction(function() { 727 | set(path("a"), "aa"); 728 | 729 | update>(path("d"), function(b) { 730 | b.push(3); 731 | }); 732 | 733 | if (get>(path("d")).length > 1) { 734 | remove(path("e", "bob")); 735 | } 736 | }, testMeta); 737 | const receivedTransactionEvent = subscriber.mock.calls[1][0]; 738 | const expectedTransactionEvent = { 739 | events: [ 740 | { meta: null, path: ["d"], type: EventType.Get, value: [0, 1, 2] }, 741 | { 742 | meta: null, 743 | path: ["a"], 744 | prevValue: "a", 745 | type: EventType.Set, 746 | value: "aa" 747 | }, 748 | { 749 | meta: null, 750 | path: ["d"], 751 | prevValue: [0, 1, 2], 752 | type: EventType.Update, 753 | value: [0, 1, 2, 3] 754 | }, 755 | { 756 | meta: null, 757 | path: ["e", "bob"], 758 | prevValue: "cool", 759 | type: EventType.Remove 760 | } 761 | ], 762 | meta: testMeta, 763 | type: "TRANSACTION" 764 | }; 765 | 766 | expect(receivedTransactionEvent).toStrictEqual(expectedTransactionEvent); 767 | }); 768 | }); 769 | }); 770 | --------------------------------------------------------------------------------