├── .changeset ├── README.md └── config.json ├── .gitignore ├── LICENSE ├── README.md ├── buildall ├── CHANGELOG.md ├── package.json └── tsconfig.json ├── demos ├── linearite │ ├── .gitignore │ ├── .prettierrc │ ├── README.md │ ├── index.html │ ├── notes.md │ ├── package.json │ ├── pnpm-lock.yaml │ ├── postcss.config.js │ ├── public │ │ ├── favicon.ico │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── netlify.toml │ │ └── robots.txt │ ├── src │ │ ├── App.tsx │ │ ├── assets │ │ │ ├── fonts │ │ │ │ ├── 27237475-28043385 │ │ │ │ ├── Inter-UI-ExtraBold.woff │ │ │ │ ├── Inter-UI-ExtraBold.woff2 │ │ │ │ ├── Inter-UI-Medium.woff │ │ │ │ ├── Inter-UI-Medium.woff2 │ │ │ │ ├── Inter-UI-Regular.woff │ │ │ │ ├── Inter-UI-Regular.woff2 │ │ │ │ ├── Inter-UI-SemiBold.woff │ │ │ │ └── Inter-UI-SemiBold.woff2 │ │ │ ├── icons │ │ │ │ ├── add-subissue.svg │ │ │ │ ├── add.svg │ │ │ │ ├── archive.svg │ │ │ │ ├── assignee.svg │ │ │ │ ├── attachment.svg │ │ │ │ ├── avatar.svg │ │ │ │ ├── cancel.svg │ │ │ │ ├── chat.svg │ │ │ │ ├── circle-dot.svg │ │ │ │ ├── circle.svg │ │ │ │ ├── claim.svg │ │ │ │ ├── close.svg │ │ │ │ ├── delete.svg │ │ │ │ ├── done.svg │ │ │ │ ├── dots.svg │ │ │ │ ├── due-date.svg │ │ │ │ ├── dupplication.svg │ │ │ │ ├── filter.svg │ │ │ │ ├── git-issue.svg │ │ │ │ ├── guide.svg │ │ │ │ ├── half-circle.svg │ │ │ │ ├── help.svg │ │ │ │ ├── inbox.svg │ │ │ │ ├── issue.svg │ │ │ │ ├── label.svg │ │ │ │ ├── menu.svg │ │ │ │ ├── parent-issue.svg │ │ │ │ ├── plus.svg │ │ │ │ ├── project.svg │ │ │ │ ├── question.svg │ │ │ │ ├── relationship.svg │ │ │ │ ├── rounded-claim.svg │ │ │ │ ├── search.svg │ │ │ │ ├── signal-medium.svg │ │ │ │ ├── signal-strong.svg │ │ │ │ ├── signal-strong.xsd │ │ │ │ ├── signal-weak.svg │ │ │ │ ├── slack.svg │ │ │ │ ├── view.svg │ │ │ │ └── zoom.svg │ │ │ └── images │ │ │ │ ├── icon.inverse.svg │ │ │ │ └── logo.svg │ │ ├── components │ │ │ ├── AboutModal.tsx │ │ │ ├── Avatar.tsx │ │ │ ├── IssueModal.tsx │ │ │ ├── ItemGroup.tsx │ │ │ ├── LeftMenu.tsx │ │ │ ├── Modal.tsx │ │ │ ├── Portal.tsx │ │ │ ├── PriorityIcon.tsx │ │ │ ├── ProfileMenu.tsx │ │ │ ├── Select.tsx │ │ │ ├── StatusIcon.tsx │ │ │ ├── Toggle.tsx │ │ │ ├── TopFilter.tsx │ │ │ ├── ViewOptionMenu.tsx │ │ │ ├── contextmenu │ │ │ │ ├── FilterMenu.tsx │ │ │ │ ├── PriorityMenu.tsx │ │ │ │ ├── StatusMenu.tsx │ │ │ │ └── menu.tsx │ │ │ └── editor │ │ │ │ ├── Editor.tsx │ │ │ │ └── EditorMenu.tsx │ │ ├── domain │ │ │ ├── SchemaType.ts │ │ │ ├── db.ts │ │ │ ├── mutations.ts │ │ │ ├── queries.ts │ │ │ └── seed.ts │ │ ├── hooks │ │ │ ├── useClickOutside.ts │ │ │ └── useLockBodyScroll.ts │ │ ├── main.tsx │ │ ├── pages │ │ │ ├── Board │ │ │ │ ├── IssueBoard.tsx │ │ │ │ ├── IssueCol.tsx │ │ │ │ ├── IssueItem.tsx │ │ │ │ └── index.tsx │ │ │ ├── Issue │ │ │ │ ├── Comments.tsx │ │ │ │ ├── DeleteModal.tsx │ │ │ │ ├── index.module.css │ │ │ │ └── index.tsx │ │ │ └── List │ │ │ │ ├── IssueList.tsx │ │ │ │ ├── IssueRow.tsx │ │ │ │ ├── VirtualTable-Cursored.tsx │ │ │ │ ├── VirtualTable-GrowingLimit.tsx │ │ │ │ ├── VirtualTable-Offset.tsx │ │ │ │ ├── VirtualTable.module.css │ │ │ │ ├── deprecated │ │ │ │ └── TanVirtualTable.tsx │ │ │ │ ├── index.module.css │ │ │ │ └── index.tsx │ │ ├── shims │ │ │ └── react-contextmenu.d.ts │ │ ├── style.css │ │ ├── types │ │ │ └── issue.ts │ │ ├── utils │ │ │ ├── date.ts │ │ │ ├── notification.tsx │ │ │ └── shallowEqual.ts │ │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts └── react │ ├── .gitignore │ ├── CHANGELOG.md │ ├── README.md │ ├── index.html │ ├── notes.md │ ├── package.json │ ├── postcss.config.js │ ├── public │ └── vite.svg │ ├── src │ ├── App.css │ ├── TaskApp.tsx │ ├── TaskComponent.tsx │ ├── TaskFilter.tsx │ ├── TaskTable.tsx │ ├── assets │ │ └── react.svg │ ├── data │ │ ├── DB.ts │ │ ├── createTasks.ts │ │ ├── randomWords.ts │ │ └── schema.ts │ ├── index.css │ ├── main.tsx │ ├── virtualized │ │ ├── OffsetVirtualTable.tsx │ │ ├── VirtualTable.module.css │ │ └── VirtualTable.tsx │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── walkthrough │ ├── app.png │ ├── filter.png │ └── walkthrough.md ├── notes.md ├── package.json ├── packages ├── ds-and-algos │ ├── CHANGELOG.md │ ├── package.json │ ├── src │ │ ├── Error.ts │ │ ├── TuplableMap.ts │ │ ├── __tests__ │ │ │ └── TuplableMap.test.ts │ │ ├── binarySearch.ts │ │ ├── minBy.ts │ │ ├── objectTracking.ts │ │ ├── scratch.ts │ │ ├── shuffle.ts │ │ ├── trees-v2 │ │ │ ├── error.ts │ │ │ ├── persistent-treap.test.ts │ │ │ ├── persistent-treap.ts │ │ │ └── types.ts │ │ ├── trees │ │ │ ├── PersistentTreap.ts │ │ │ ├── RedBlackMap.ts │ │ │ ├── RedBlackTree.ts │ │ │ ├── Treap.ts │ │ │ ├── TreeBase.ts │ │ │ ├── TreeIterator.ts │ │ │ └── __tests__ │ │ │ │ ├── ImmVsTreap.test.ts │ │ │ │ ├── PersistentTreap.test.ts │ │ │ │ ├── PersistentTreapPerf.test.ts │ │ │ │ ├── RedBlackMap.test.ts │ │ │ │ ├── RedBlackTree.test.ts │ │ │ │ ├── RedBlackTreePerf.test.ts │ │ │ │ ├── Treap.test.ts │ │ │ │ ├── TreapFastCheck.test.ts │ │ │ │ └── scratch.test.ts │ │ ├── tuple.ts │ │ └── types.ts │ └── tsconfig.json ├── materialite │ ├── CHANGELOG.md │ ├── docs │ │ └── graph.png │ ├── package.json │ ├── src │ │ ├── __tests__ │ │ │ ├── after.test.ts │ │ │ ├── cleanup.test.ts │ │ │ ├── hoist.test.ts │ │ │ ├── lazy.test.ts │ │ │ ├── materialite.test.ts │ │ │ ├── mutableSource.test.ts │ │ │ ├── notification.test.ts │ │ │ ├── pull.test.ts │ │ │ ├── signals.test.ts │ │ │ ├── sortedSource.test.ts │ │ │ ├── stress.test.ts │ │ │ ├── take.test.ts │ │ │ └── transaction.test.ts │ │ ├── core │ │ │ ├── __tests__ │ │ │ │ ├── consolidation.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ └── multiset.test.ts │ │ │ ├── consolidation.ts │ │ │ ├── debug.ts │ │ │ ├── graph │ │ │ │ ├── AbstractDifferenceStream.ts │ │ │ │ ├── DifferenceReader.ts │ │ │ │ ├── DifferenceStream.ts │ │ │ │ ├── DifferenceWriter.ts │ │ │ │ ├── HoistableDifferenceStream.ts │ │ │ │ ├── IDifferenceStream.ts │ │ │ │ ├── Msg.ts │ │ │ │ ├── Queue.ts │ │ │ │ ├── RootDifferenceStream.ts │ │ │ │ └── ops │ │ │ │ │ ├── AfterOperator.ts │ │ │ │ │ ├── BinaryOperator.ts │ │ │ │ │ ├── ConcatOperator.ts │ │ │ │ │ ├── CountOperator.ts │ │ │ │ │ ├── DistinctOperator.ts │ │ │ │ │ ├── EffectOperator.ts │ │ │ │ │ ├── FilterOperator.ts │ │ │ │ │ ├── HoistableAfterOperator.ts │ │ │ │ │ ├── JoinOperator.ts │ │ │ │ │ ├── LinearUnaryOperator.ts │ │ │ │ │ ├── MapOperator.ts │ │ │ │ │ ├── MaxOperator.ts │ │ │ │ │ ├── MinOperator.ts │ │ │ │ │ ├── NegateOperator.ts │ │ │ │ │ ├── Operator.ts │ │ │ │ │ ├── ReduceOperator.ts │ │ │ │ │ ├── SplitOperator.ts │ │ │ │ │ ├── TakeOperator.ts │ │ │ │ │ └── UnaryOperator.ts │ │ │ ├── index.ts │ │ │ ├── multiset.ts │ │ │ └── types.ts │ │ ├── index.ts │ │ ├── materialite.ts │ │ ├── signal │ │ │ ├── Atom.ts │ │ │ ├── ISignal.ts │ │ │ └── Thunk.ts │ │ ├── sources │ │ │ ├── ImmutableSetSource.ts │ │ │ ├── MutableMapSource.ts │ │ │ ├── MutableSetSource.ts │ │ │ ├── Source.ts │ │ │ ├── StatefulSetSource.ts │ │ │ ├── StatelessSetSource.ts │ │ │ └── __tests__ │ │ │ │ └── MutableSetSource.test.ts │ │ └── views │ │ │ ├── ArrayView.ts │ │ │ ├── CopyOnWriteArrayView.ts │ │ │ ├── DOMView.ts │ │ │ ├── PersistentTreeView.ts │ │ │ ├── PrimitiveView.ts │ │ │ ├── View.ts │ │ │ ├── __tests__ │ │ │ ├── PersistentTreeView.test.ts │ │ │ └── noStaleView.test.ts │ │ │ ├── notes.md │ │ │ └── updateMutableArray.ts │ └── tsconfig.json └── react │ ├── CHANGELOG.md │ ├── notes.md │ ├── package.json │ ├── src │ ├── hooks.ts │ └── index.ts │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── todo.md └── vitest.workspace.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | tsconfig.tsbuildinfo 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /buildall/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @vlcn.io/tsbuild-all 2 | 3 | ## 0.13.3 4 | 5 | ### Patch Changes 6 | 7 | - fix error where paths that did not ask for a recompute are notified of a recompute 8 | 9 | ## 0.13.2 10 | 11 | ### Patch Changes 12 | 13 | - prevent tearing by emulating useEffect via useState 14 | -------------------------------------------------------------------------------- /buildall/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vlcn.io/tsbuild-all", 3 | "version": "0.13.3", 4 | "main": "lib/index.js", 5 | "type": "module", 6 | "devDependencies": { 7 | "typescript": "^5.2.2" 8 | }, 9 | "scripts": { 10 | "clean": "tsc --build --clean", 11 | "build": "tsc --build", 12 | "watch": "tsc --build -w", 13 | "deep-clean": "rm -rf ./dist || true && rm tsconfig.tsbuildinfo || true" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /buildall/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "rootDir": "./src" 5 | }, 6 | "include": ["./src/"], 7 | "references": [ 8 | { "path": "../packages/materialite" }, 9 | { "path": "../packages/ds-and-algos" }, 10 | { "path": "../packages/react" } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /demos/linearite/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | dist/ 4 | -------------------------------------------------------------------------------- /demos/linearite/.prettierrc: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /demos/linearite/README.md: -------------------------------------------------------------------------------- 1 | # linearite 2 | 3 | 4 | https://github.com/vlcn-io/linearite/assets/1009003/377b64aa-3b2d-4333-8be9-931855dd77d7 5 | 6 | 7 | ## TODO: 8 | - [ ] Deploy to fly.io 9 | - [ ] A guide that walks through the implementation of this 10 | - [ ] Paginated transaction sync -- right now we sync all 20,000 rows at once since they were written in a single transaction. 11 | - [ ] Dev tools to simplify dropping the DB on the client 12 | - [ ] Dev tools to download a copy of the DB from the browser 13 | - [ ] Scale the example to 10 million issues 14 | 15 | ## Running 16 | 17 | ```sh 18 | git clone git@github.com:vlcn-io/linearite.git 19 | pnpm install 20 | pnpm dev 21 | ``` 22 | 23 | If you're changing `Schema.ts` run `typed-sql` to auto-generate static types. 24 | 25 | ```sh 26 | pnpm sql-watch 27 | ``` 28 | -------------------------------------------------------------------------------- /demos/linearite/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | LinearLite 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /demos/linearite/notes.md: -------------------------------------------------------------------------------- 1 | - use react-table w/ offset pagination? 2 | 3 | - create a side index for the offsets? 4 | 5 | - revive rx-cache? 6 | 1. Prepare the stmt 7 | 2. Cache prepared stmts 8 | 3. Run all impacted in 1 wasm bound traverse 9 | 4. default order by for diffing? 10 | 5. 11 | -------------------------------------------------------------------------------- /demos/linearite/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /demos/linearite/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlcn-io/materialite/e08c1fa176883a51296d7c2c3a86c539c0e72eab/demos/linearite/public/favicon.ico -------------------------------------------------------------------------------- /demos/linearite/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlcn-io/materialite/e08c1fa176883a51296d7c2c3a86c539c0e72eab/demos/linearite/public/logo192.png -------------------------------------------------------------------------------- /demos/linearite/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlcn-io/materialite/e08c1fa176883a51296d7c2c3a86c539c0e72eab/demos/linearite/public/logo512.png -------------------------------------------------------------------------------- /demos/linearite/public/netlify.toml: -------------------------------------------------------------------------------- 1 | 2 | [[redirects]] 3 | from = "/*" 4 | to = "/index.html" 5 | status = 200 6 | -------------------------------------------------------------------------------- /demos/linearite/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /demos/linearite/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "animate.css/animate.min.css"; 2 | import Board from "./pages/Board"; 3 | import { useState, createContext } from "react"; 4 | import { Route, Routes, BrowserRouter } from "react-router-dom"; 5 | import { cssTransition, ToastContainer } from "react-toastify"; 6 | import "react-toastify/dist/ReactToastify.css"; 7 | import List from "./pages/List"; 8 | import LeftMenu from "./components/LeftMenu"; 9 | import { FPSMeter } from "@schickling/fps-meter"; 10 | 11 | interface MenuContextInterface { 12 | showMenu: boolean; 13 | setShowMenu: (show: boolean) => void; 14 | } 15 | 16 | export const MenuContext = createContext(null as MenuContextInterface | null); 17 | 18 | const slideUp = cssTransition({ 19 | enter: "animate__animated animate__slideInUp", 20 | exit: "animate__animated animate__slideOutDown", 21 | }); 22 | 23 | const App = () => { 24 | const [showMenu, setShowMenu] = useState(false); 25 | 26 | const router = ( 27 | 28 | } /> 29 | } /> 30 | 31 | ); 32 | 33 | return ( 34 | 35 | 39 | 40 |
41 | 42 | {router} 43 |
44 | 56 |
57 |
58 | ); 59 | }; 60 | 61 | export default App; 62 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/fonts/27237475-28043385: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlcn-io/materialite/e08c1fa176883a51296d7c2c3a86c539c0e72eab/demos/linearite/src/assets/fonts/27237475-28043385 -------------------------------------------------------------------------------- /demos/linearite/src/assets/fonts/Inter-UI-ExtraBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlcn-io/materialite/e08c1fa176883a51296d7c2c3a86c539c0e72eab/demos/linearite/src/assets/fonts/Inter-UI-ExtraBold.woff -------------------------------------------------------------------------------- /demos/linearite/src/assets/fonts/Inter-UI-ExtraBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlcn-io/materialite/e08c1fa176883a51296d7c2c3a86c539c0e72eab/demos/linearite/src/assets/fonts/Inter-UI-ExtraBold.woff2 -------------------------------------------------------------------------------- /demos/linearite/src/assets/fonts/Inter-UI-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlcn-io/materialite/e08c1fa176883a51296d7c2c3a86c539c0e72eab/demos/linearite/src/assets/fonts/Inter-UI-Medium.woff -------------------------------------------------------------------------------- /demos/linearite/src/assets/fonts/Inter-UI-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlcn-io/materialite/e08c1fa176883a51296d7c2c3a86c539c0e72eab/demos/linearite/src/assets/fonts/Inter-UI-Medium.woff2 -------------------------------------------------------------------------------- /demos/linearite/src/assets/fonts/Inter-UI-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlcn-io/materialite/e08c1fa176883a51296d7c2c3a86c539c0e72eab/demos/linearite/src/assets/fonts/Inter-UI-Regular.woff -------------------------------------------------------------------------------- /demos/linearite/src/assets/fonts/Inter-UI-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlcn-io/materialite/e08c1fa176883a51296d7c2c3a86c539c0e72eab/demos/linearite/src/assets/fonts/Inter-UI-Regular.woff2 -------------------------------------------------------------------------------- /demos/linearite/src/assets/fonts/Inter-UI-SemiBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlcn-io/materialite/e08c1fa176883a51296d7c2c3a86c539c0e72eab/demos/linearite/src/assets/fonts/Inter-UI-SemiBold.woff -------------------------------------------------------------------------------- /demos/linearite/src/assets/fonts/Inter-UI-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlcn-io/materialite/e08c1fa176883a51296d7c2c3a86c539c0e72eab/demos/linearite/src/assets/fonts/Inter-UI-SemiBold.woff2 -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/add-subissue.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/archive.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/assignee.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/attachment.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/avatar.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/cancel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/chat.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/circle-dot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/circle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/claim.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/delete.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/done.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/dots.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/due-date.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/dupplication.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/filter.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/git-issue.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/guide.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/half-circle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/help.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/inbox.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/issue.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/label.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/menu.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/parent-issue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/project.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/question.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/relationship.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/rounded-claim.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/signal-medium.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/signal-strong.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/signal-strong.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/signal-weak.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/view.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/icons/zoom.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/images/icon.inverse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /demos/linearite/src/assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demos/linearite/src/components/AboutModal.tsx: -------------------------------------------------------------------------------- 1 | import Modal from './Modal' 2 | 3 | interface Props { 4 | isOpen: boolean 5 | onDismiss?: () => void 6 | } 7 | 8 | export default function AboutModal({ isOpen, onDismiss }: Props) { 9 | return ( 10 | 11 |
12 |

13 | This is an example of a team collaboration app such as{' '} 14 | 15 | Linear 16 | {' '} 17 | built using{' '} 18 | 19 | Vulcan 20 | {' '} 21 | - the local-first sync layer for web and mobile apps. 22 |

23 |

24 | This example is built on top of the excellent clone of the Linear UI built by{' '} 25 | 26 | Tuan Nguyen 27 | 28 | . 29 |

30 |

31 | We have replaced the canned data with a stack running{' '} 32 | 33 | Vulcan 34 | 35 |

36 |
37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /demos/linearite/src/components/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEventHandler } from 'react' 2 | import classnames from 'classnames' 3 | import AvatarImg from '../assets/icons/avatar.svg' 4 | 5 | interface Props { 6 | online?: boolean 7 | showOffline?: boolean 8 | name?: string 9 | avatarUrl?: string 10 | onClick?: MouseEventHandler | undefined 11 | } 12 | 13 | //bg-blue-500 14 | 15 | function stringToHslColor(str: string, s: number, l: number) { 16 | let hash = 0 17 | for (let i = 0; i < str.length; i++) { 18 | hash = str.charCodeAt(i) + ((hash << 5) - hash) 19 | } 20 | 21 | const h = hash % 360 22 | return 'hsl(' + h + ', ' + s + '%, ' + l + '%)' 23 | } 24 | 25 | function getAcronym(name: string) { 26 | let acr = ((name || '').match(/\b(\w)/g) || []).join('').slice(0, 2).toUpperCase() 27 | if (acr.length === 1) { 28 | acr = acr + name.slice(1, 2).toLowerCase() 29 | } 30 | return acr 31 | } 32 | function Avatar({ online, showOffline, name, onClick, avatarUrl }: Props) { 33 | let avatar, status 34 | 35 | // create avatar image icon 36 | if (avatarUrl) avatar = {name} 37 | else if (name !== undefined) { 38 | // use name as avatar 39 | avatar = ( 40 |
44 | {getAcronym(name)} 45 |
46 | ) 47 | } else { 48 | // try to use default avatar 49 | avatar = avatar 50 | } 51 | 52 | //status icon 53 | if (online || showOffline) 54 | status = ( 55 | // 56 | 62 | ) 63 | else status = null 64 | 65 | return ( 66 |
67 | {avatar} 68 | {status} 69 |
70 | ) 71 | } 72 | 73 | export default Avatar 74 | -------------------------------------------------------------------------------- /demos/linearite/src/components/ItemGroup.tsx: -------------------------------------------------------------------------------- 1 | import { BsFillCaretDownFill, BsFillCaretRightFill } from 'react-icons/bs' 2 | import * as React from 'react' 3 | import { useState } from 'react' 4 | 5 | interface Props { 6 | title: string 7 | children: React.ReactNode 8 | } 9 | function ItemGroup({ title, children }: Props) { 10 | const [showItems, setShowItems] = useState(true) 11 | 12 | const Icon = showItems ? BsFillCaretDownFill : BsFillCaretRightFill 13 | return ( 14 |
15 | 22 | {showItems && children} 23 |
24 | ) 25 | } 26 | 27 | export default ItemGroup 28 | -------------------------------------------------------------------------------- /demos/linearite/src/components/Portal.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useState } from 'react' 2 | import { useEffect } from 'react' 3 | import { createPortal } from 'react-dom' 4 | 5 | //Copied from https://github.com/tailwindlabs/headlessui/blob/71730fea1291e572ae3efda16d8644f870d87750/packages/%40headlessui-react/pages/menu/menu-with-popper.tsx#L90 6 | export function Portal(props: { children: ReactNode }) { 7 | const { children } = props 8 | const [mounted, setMounted] = useState(false) 9 | 10 | useEffect(() => setMounted(true), []) 11 | 12 | if (!mounted) return null 13 | return createPortal(children, document.body) 14 | } 15 | -------------------------------------------------------------------------------- /demos/linearite/src/components/PriorityIcon.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { PriorityIcons } from '../types/issue' 3 | import { PriorityType } from '../domain/SchemaType' 4 | 5 | interface Props { 6 | priority: PriorityType 7 | className?: string 8 | } 9 | 10 | export default function PriorityIcon({ priority, className }: Props) { 11 | const classes = classNames('w-4 h-4', className) 12 | const Icon = PriorityIcons[priority] 13 | return 14 | } 15 | -------------------------------------------------------------------------------- /demos/linearite/src/components/ProfileMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Transition } from '@headlessui/react' 2 | import classnames from 'classnames' 3 | import { useClickOutside } from '../hooks/useClickOutside' 4 | import Toggle from './Toggle' 5 | import { useRef } from 'react' 6 | 7 | interface Props { 8 | isOpen: boolean 9 | onDismiss?: () => void 10 | setShowAboutModal?: (show: boolean) => void 11 | className?: string 12 | } 13 | export default function ProfileMenu({ isOpen, className, onDismiss, setShowAboutModal }: Props) { 14 | // const { connectivityState, toggleConnectivityState } = useConnectivityState() 15 | const connectivityState: string = 'connected' 16 | const toggleConnectivityState = () => {} 17 | const classes = classnames( 18 | 'select-none w-53 shadow-modal z-50 flex flex-col py-1 bg-white font-normal rounded text-gray-800', 19 | className, 20 | ) 21 | const ref = useRef(null) 22 | 23 | const connectivityConnected = connectivityState !== 'disconnected' 24 | const connectivityStateDisplay = connectivityState[0].toUpperCase() + connectivityState.slice(1) 25 | 26 | useClickOutside(ref, () => { 27 | if (isOpen && onDismiss) { 28 | onDismiss() 29 | } 30 | }) 31 | 32 | return ( 33 |
34 | 44 | 53 | 54 | Visit Vulcan 55 | 56 | 57 | Documentation 58 | 59 | 63 | GitHub 64 | 65 |
66 | {connectivityStateDisplay} 67 | 73 |
74 |
75 |
76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /demos/linearite/src/components/Select.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { ReactNode } from 'react' 3 | 4 | interface Props { 5 | className?: string 6 | children: ReactNode 7 | defaultValue?: string | number | ReadonlyArray 8 | value?: string | number | ReadonlyArray 9 | onChange?: (event: React.ChangeEvent) => void 10 | } 11 | export default function Select(props: Props) { 12 | const { children, defaultValue, className, value, onChange, ...rest } = props 13 | 14 | const classes = classNames( 15 | 'form-select text-xs focus:ring-transparent form-select text-gray-800 h-6 bg-gray-100 rounded pr-4.5 bg-right pl-2 py-0 appearance-none focus:outline-none border-none', 16 | className, 17 | ) 18 | return ( 19 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /demos/linearite/src/components/StatusIcon.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { StatusIcons } from '../types/issue' 3 | import { StatusType } from '../domain/SchemaType' 4 | 5 | interface Props { 6 | status: StatusType 7 | className?: string 8 | } 9 | 10 | export default function StatusIcon({ status, className }: Props) { 11 | const classes = classNames('w-3.5 h-3.5 rounded', className) 12 | 13 | const Icon = StatusIcons[status] 14 | return 15 | } 16 | -------------------------------------------------------------------------------- /demos/linearite/src/components/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | 3 | interface Props { 4 | onChange?: (value: boolean) => void 5 | className?: string 6 | value?: boolean 7 | activeClass?: string 8 | activeLabelClass?: string 9 | } 10 | export default function Toggle({ 11 | onChange, 12 | className, 13 | value = false, 14 | activeClass = 'bg-indigo-600 hover:bg-indigo-700', 15 | activeLabelClass = 'border-indigo-600', 16 | }: Props) { 17 | const labelClasses = classnames( 18 | 'absolute h-3.5 w-3.5 overflow-hidden border-2 transition duration-200 ease-linear rounded-full cursor-pointer bg-white', 19 | { 20 | 'left-0 border-gray-300': !value, 21 | 'right-0': value, 22 | [activeLabelClass]: value, 23 | }, 24 | ) 25 | const classes = classnames( 26 | 'group relative rounded-full w-5 h-3.5 transition duration-200 ease-linear', 27 | { 28 | [activeClass]: value, 29 | 'bg-gray-300': !value, 30 | }, 31 | className, 32 | ) 33 | const onClick = () => { 34 | if (onChange) onChange(!value) 35 | } 36 | return ( 37 |
38 | 39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /demos/linearite/src/components/contextmenu/PriorityMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Portal } from '../Portal' 2 | import { ReactNode, useState } from 'react' 3 | import { ContextMenuTrigger } from '@firefox-devtools/react-contextmenu' 4 | import { Menu } from './menu' 5 | import { PriorityOptions } from '../../types/issue' 6 | import { PriorityType } from '../../domain/SchemaType' 7 | 8 | interface Props { 9 | id: string 10 | button: ReactNode 11 | filterKeyword: boolean 12 | className?: string 13 | onSelect?: (item: PriorityType) => void 14 | } 15 | 16 | function PriorityMenu({ id, button, filterKeyword, className, onSelect }: Props) { 17 | const [keyword, setKeyword] = useState('') 18 | 19 | const handleSelect = (priority: PriorityType) => { 20 | setKeyword('') 21 | if (onSelect) onSelect(priority) 22 | } 23 | let statusOpts = PriorityOptions 24 | if (keyword !== '') { 25 | const normalizedKeyword = keyword.toLowerCase().trim() 26 | statusOpts = statusOpts.filter( 27 | ([_Icon, _priority, label]) => (label as string).toLowerCase().indexOf(normalizedKeyword) !== -1, 28 | ) 29 | } 30 | 31 | const options = statusOpts.map(([Icon, priority, label], idx) => { 32 | return ( 33 | handleSelect(priority)}> 34 | {label} 35 | 36 | ) 37 | }) 38 | 39 | return ( 40 | <> 41 | 42 | {button} 43 | 44 | 45 | 46 | setKeyword(kw)} 52 | className={className} 53 | > 54 | {options} 55 | 56 | 57 | 58 | ) 59 | } 60 | 61 | PriorityMenu.defaultProps = { 62 | filterKeyword: false, 63 | } 64 | 65 | export default PriorityMenu 66 | -------------------------------------------------------------------------------- /demos/linearite/src/components/contextmenu/StatusMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Portal } from '../Portal' 2 | import { ReactNode, useState } from 'react' 3 | import { ContextMenuTrigger } from '@firefox-devtools/react-contextmenu' 4 | import { StatusOptions } from '../../types/issue' 5 | import { Menu } from './menu' 6 | import { StatusType } from '../../domain/SchemaType' 7 | 8 | interface Props { 9 | id: string 10 | button: ReactNode 11 | className?: string 12 | onSelect?: (item: StatusType) => void 13 | } 14 | export default function StatusMenu({ id, button, className, onSelect }: Props) { 15 | const [keyword, setKeyword] = useState('') 16 | const handleSelect = (status: StatusType) => { 17 | if (onSelect) onSelect(status) 18 | } 19 | 20 | let statuses = StatusOptions 21 | if (keyword !== '') { 22 | const normalizedKeyword = keyword.toLowerCase().trim() 23 | statuses = statuses.filter(([_icon, _id, l]) => l.toLowerCase().indexOf(normalizedKeyword) !== -1) 24 | } 25 | 26 | const options = statuses.map(([Icon, id, label]) => { 27 | return ( 28 | handleSelect(id)}> 29 | 30 |
{label}
31 |
32 | ) 33 | }) 34 | 35 | return ( 36 | <> 37 | 38 | {button} 39 | 40 | 41 | 42 | setKeyword(kw)} 49 | > 50 | {options} 51 | 52 | 53 | 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /demos/linearite/src/components/editor/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { useEditor, EditorContent, BubbleMenu, type Extensions } from '@tiptap/react' 2 | import StarterKit from '@tiptap/starter-kit' 3 | import Placeholder from '@tiptap/extension-placeholder' 4 | import Table from '@tiptap/extension-table' 5 | import TableCell from '@tiptap/extension-table-cell' 6 | import TableHeader from '@tiptap/extension-table-header' 7 | import TableRow from '@tiptap/extension-table-row' 8 | // @ts-ignore 9 | import { Markdown } from 'tiptap-markdown' 10 | import EditorMenu from './EditorMenu' 11 | import { useEffect, useRef } from 'react' 12 | 13 | interface EditorProps { 14 | value: string 15 | onChange: (value: string) => void 16 | className?: string 17 | placeholder?: string 18 | } 19 | 20 | const Editor = ({ value, onChange, className = '', placeholder }: EditorProps) => { 21 | const editorProps = { 22 | attributes: { 23 | class: className, 24 | }, 25 | } 26 | const markdownValue = useRef(null) 27 | 28 | const extensions: Extensions = [StarterKit, Markdown, Table, TableRow, TableHeader, TableCell] 29 | 30 | const editor = useEditor({ 31 | extensions, 32 | editorProps, 33 | content: value || undefined, 34 | onUpdate: ({ editor }) => { 35 | markdownValue.current = editor.storage.markdown.getMarkdown() 36 | onChange(markdownValue.current || '') 37 | }, 38 | }) 39 | 40 | useEffect(() => { 41 | if (editor && markdownValue.current !== value) { 42 | editor.commands.setContent(value) 43 | } 44 | }, [value, editor]) 45 | 46 | if (placeholder) { 47 | extensions.push( 48 | Placeholder.configure({ 49 | placeholder, 50 | }), 51 | ) 52 | } 53 | 54 | return ( 55 | <> 56 | 57 | {editor && ( 58 | 59 | 60 | 61 | )} 62 | 63 | ) 64 | } 65 | 66 | export default Editor 67 | -------------------------------------------------------------------------------- /demos/linearite/src/domain/SchemaType.ts: -------------------------------------------------------------------------------- 1 | export type Issue = SchemaType["issue"]; 2 | export type Description = SchemaType["description"]; 3 | export type Comment = SchemaType["comment"]; 4 | 5 | namespace Symbols { 6 | export declare const brand: unique symbol; 7 | } 8 | 9 | export type Order = keyof Issue; 10 | export type AppState = FilterState | SelectionState; 11 | export type FilterState = { 12 | _tag: "filter"; 13 | orderBy: Order; 14 | orderDirection: "asc" | "desc"; 15 | status: StatusType[] | null; 16 | priority: PriorityType[] | null; 17 | query: string | null; 18 | }; 19 | export const defaultFilterState: FilterState = { 20 | _tag: "filter", 21 | orderBy: "modified", 22 | orderDirection: "desc", 23 | status: null, 24 | priority: null, 25 | query: null, 26 | }; 27 | 28 | export type SelectionState = { 29 | _tag: "selected"; 30 | id: number; 31 | }; 32 | 33 | export type StatusType = 34 | | "backlog" 35 | | "todo" 36 | | "in_progress" 37 | | "done" 38 | | "canceled"; 39 | export type PriorityType = "none" | "urgent" | "high" | "low" | "medium"; 40 | export type ID_of = number & { readonly [Symbols.brand]: T }; 41 | 42 | export type SchemaType = { 43 | readonly issue: Readonly<{ 44 | id: ID_of; 45 | title: string; 46 | creator: string; 47 | priority: PriorityType; 48 | status: StatusType; 49 | created: number; 50 | modified: number; 51 | kanbanorder: string; 52 | }>; 53 | readonly description: Readonly<{ 54 | id: ID_of; 55 | body: string; 56 | }>; 57 | readonly comment: Readonly<{ 58 | id: ID_of; 59 | body: string; 60 | creator: string; 61 | issueId: ID_of; 62 | created: number; 63 | }>; 64 | }; 65 | -------------------------------------------------------------------------------- /demos/linearite/src/domain/mutations.ts: -------------------------------------------------------------------------------- 1 | import { Issue, Description, Comment, FilterState } from "./SchemaType"; 2 | import { db } from "./db"; 3 | 4 | // TODO: prepare mutation statements 5 | // TODO: tables used cache for writes 6 | export const mutations = { 7 | putIssue(issue: Issue) { 8 | db.tx(() => { 9 | const existing = db.issues.base.value.get(issue.id); 10 | if (existing) { 11 | db.issues.delete(existing); 12 | } 13 | 14 | db.issues.add({ 15 | ...issue, 16 | modified: Date.now(), 17 | }); 18 | }); 19 | }, 20 | 21 | putDescription(desc: Description) { 22 | db.descriptions.add(desc); 23 | }, 24 | 25 | putIssueWithDescription(issue: Issue, desc: Description) { 26 | db.tx(() => { 27 | db.issues.add(issue); 28 | db.descriptions.add(desc); 29 | }); 30 | }, 31 | 32 | putComment(comment: Comment) { 33 | db.comments.add(comment); 34 | }, 35 | 36 | putFilterState(state: FilterState) { 37 | db.appState.add(state); 38 | }, 39 | 40 | moveIssue() {}, 41 | 42 | deleteIssue(issue: Issue) { 43 | db.tx(() => { 44 | db.issues.delete(issue); 45 | db.descriptions.delete({ 46 | id: issue.id, 47 | body: "", 48 | }); 49 | // TODO: we need a way to delete all comments for an issue 50 | // we can fetch all comments in a range since they're sorted by issue id. 51 | }); 52 | }, 53 | }; 54 | -------------------------------------------------------------------------------- /demos/linearite/src/domain/queries.ts: -------------------------------------------------------------------------------- 1 | import { DifferenceStream } from "@vlcn.io/materialite"; 2 | import { DB } from "./db"; 3 | import { 4 | FilterState, 5 | Issue, 6 | SelectionState, 7 | StatusType, 8 | defaultFilterState, 9 | } from "./SchemaType"; 10 | export const queries = { 11 | filters(db: DB) { 12 | return db.appState.stream 13 | .filter((s): s is FilterState => s._tag === "filter") 14 | .materializeValue(defaultFilterState); 15 | }, 16 | 17 | selected(db: DB) { 18 | return db.appState.stream 19 | .filter((s): s is SelectionState => s._tag === "selected") 20 | .materializeValue(null); 21 | }, 22 | 23 | issues(db: DB, filterState: FilterState) { 24 | const source = db.issues.getSortedSource(filterState.orderBy); 25 | return applyFilters(source.stream, filterState).materialize( 26 | source.comparator 27 | ); 28 | }, 29 | 30 | filteredIssuesCount(db: DB, filterState: FilterState) { 31 | const source = db.issues.getSortedSource(filterState.orderBy); 32 | return applyFilters(source.stream, filterState).size().materializeValue(0); 33 | }, 34 | 35 | kanbanSection(db: DB, status: StatusType, filterState: FilterState) { 36 | const source = db.issues.getSortedSource("kanbanorder"); 37 | return applyFilters( 38 | source.stream.filter((i) => i.status === status), 39 | filterState 40 | ).materialize(source.comparator); 41 | }, 42 | } as const; 43 | 44 | export function applyFilters( 45 | stream: DifferenceStream, 46 | filterState: FilterState 47 | ) { 48 | if (filterState.query) { 49 | stream = stream.filter((i) => 50 | i.title.toLowerCase().includes(filterState.query!.toLowerCase()) 51 | ); 52 | } 53 | if (filterState.status) { 54 | stream = stream.filter((i) => filterState.status!.includes(i.status)); 55 | } 56 | if (filterState.priority) { 57 | stream = stream.filter((i) => filterState.priority!.includes(i.priority)); 58 | } 59 | return stream; 60 | } 61 | -------------------------------------------------------------------------------- /demos/linearite/src/hooks/useClickOutside.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useCallback, useEffect } from 'react' 2 | 3 | export const useClickOutside = ( 4 | ref: RefObject, 5 | callback: (event: MouseEvent | TouchEvent) => void, 6 | outerRef?: RefObject, 7 | ): void => { 8 | const handleClick = useCallback( 9 | (event: MouseEvent | TouchEvent) => { 10 | if (!event.target || outerRef?.current?.contains(event.target as Node)) { 11 | return 12 | } 13 | if (ref.current && !ref.current.contains(event.target as Node)) { 14 | callback(event) 15 | } 16 | }, 17 | [callback, ref, outerRef], 18 | ) 19 | useEffect(() => { 20 | document.addEventListener('mousedown', handleClick) 21 | document.addEventListener('touchstart', handleClick) 22 | 23 | return () => { 24 | document.removeEventListener('mousedown', handleClick) 25 | document.removeEventListener('touchstart', handleClick) 26 | } 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /demos/linearite/src/hooks/useLockBodyScroll.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from 'react' 2 | 3 | export default function useLockBodyScroll() { 4 | useLayoutEffect(() => { 5 | // Get original value of body overflow 6 | const originalStyle = window.getComputedStyle(document.body).overflow 7 | // Prevent scrolling on mount 8 | document.body.style.overflow = 'hidden' 9 | // Re-enable scrolling when component unmounts 10 | return () => { 11 | document.body.style.overflow = originalStyle 12 | } 13 | }, []) // Empty array ensures effect is only run on mount and unmount 14 | } 15 | -------------------------------------------------------------------------------- /demos/linearite/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import "./style.css"; 3 | 4 | import App from "./App"; 5 | 6 | const container = document.getElementById("root")!; 7 | const root = createRoot(container); 8 | root.render(); 9 | -------------------------------------------------------------------------------- /demos/linearite/src/pages/Board/IssueItem.tsx: -------------------------------------------------------------------------------- 1 | import { memo, type CSSProperties } from "react"; 2 | import classNames from "classnames"; 3 | import { useNavigate } from "react-router-dom"; 4 | import { DraggableProvided } from "react-beautiful-dnd"; 5 | import Avatar from "../../components/Avatar"; 6 | import PriorityMenu from "../../components/contextmenu/PriorityMenu"; 7 | import PriorityIcon from "../../components/PriorityIcon"; 8 | import { Issue, PriorityType } from "../../domain/SchemaType"; 9 | import { mutations } from "../../domain/mutations"; 10 | import { db } from "../../domain/db"; 11 | 12 | interface IssueProps { 13 | issue: Issue; 14 | index: number; 15 | isDragging?: boolean; 16 | provided: DraggableProvided; 17 | style?: CSSProperties; 18 | } 19 | 20 | export const itemHeight = 100; 21 | 22 | function getStyle( 23 | provided: DraggableProvided, 24 | style?: CSSProperties 25 | ): CSSProperties { 26 | return { 27 | ...provided.draggableProps.style, 28 | ...(style || {}), 29 | height: `${itemHeight}px`, 30 | }; 31 | } 32 | 33 | // eslint-disable-next-line react-refresh/only-export-components 34 | const IssueItem = ({ issue, style, isDragging, provided }: IssueProps) => { 35 | const navigate = useNavigate(); 36 | const priorityIcon = ( 37 | 38 | 39 | 40 | ); 41 | 42 | const updatePriority = (priority: PriorityType) => 43 | mutations.putIssue({ 44 | ...db.issues.get(issue.id)!, 45 | priority, 46 | }); 47 | 48 | return ( 49 |
navigate(`/issue/${issue.id}`)} 61 | > 62 |
63 |
64 | 65 | {issue.title} 66 | 67 |
68 |
69 | 70 |
71 |
72 |
73 | updatePriority(p)} 78 | /> 79 |
80 |
81 | ); 82 | }; 83 | 84 | const memoed = memo(IssueItem); 85 | export default memoed; 86 | -------------------------------------------------------------------------------- /demos/linearite/src/pages/Board/index.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@vlcn.io/materialite-react"; 2 | import TopFilter from "../../components/TopFilter"; 3 | import { db } from "../../domain/db"; 4 | import { queries } from "../../domain/queries"; 5 | import IssueBoard from "./IssueBoard"; 6 | 7 | function Board() { 8 | const [, filterState] = useQuery(() => queries.filters(db), []); 9 | const [, filteredIssuesCount] = useQuery( 10 | () => queries.filteredIssuesCount(db, filterState!), 11 | [filterState] 12 | ); 13 | 14 | return ( 15 |
16 | 17 | 18 |
19 | ); 20 | } 21 | 22 | export default Board; 23 | -------------------------------------------------------------------------------- /demos/linearite/src/pages/Issue/Comments.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | import ReactMarkdown from "react-markdown"; 3 | import Editor from "../../components/editor/Editor"; 4 | import Avatar from "../../components/Avatar"; 5 | import { formatDate } from "../../utils/date"; 6 | import { showWarning } from "../../utils/notification"; 7 | import { Comment, Issue } from "../../domain/SchemaType"; 8 | import { mutations } from "../../domain/mutations"; 9 | import { db } from "../../domain/db"; 10 | 11 | export interface CommentsProps { 12 | issue: Issue; 13 | } 14 | 15 | function Comments({ issue }: CommentsProps) { 16 | const [newCommentBody, setNewCommentBody] = useState(""); 17 | // See https://github.com/vlcn-io/materialite/discussions/15 18 | const comments: Comment[] = []; 19 | 20 | const commentList = () => { 21 | if (comments && comments.length > 0) { 22 | return comments.map((comment) => ( 23 |
27 |
28 | 29 | 30 | {comment.creator} 31 | 32 | 33 | {formatDate(new Date(comment.created))} 34 | 35 |
36 |
37 | {comment.body} 38 |
39 |
40 | )); 41 | } 42 | }; 43 | 44 | const handlePost = () => { 45 | if (!newCommentBody) { 46 | showWarning( 47 | "Please enter a comment before submitting", 48 | "Comment required" 49 | ); 50 | return; 51 | } 52 | 53 | const comment: Comment = { 54 | id: db.nextId(), 55 | body: newCommentBody, 56 | issueId: issue.id, 57 | created: Date.now(), 58 | creator: "testuser", 59 | }; 60 | mutations.putComment(comment); 61 | setNewCommentBody(""); 62 | }; 63 | 64 | return ( 65 | <> 66 | {commentList()} 67 | setNewCommentBody(val)} 71 | placeholder="Add a comment..." 72 | /> 73 |
74 | 80 |
81 | 82 | ); 83 | } 84 | 85 | export default Comments; 86 | -------------------------------------------------------------------------------- /demos/linearite/src/pages/Issue/DeleteModal.tsx: -------------------------------------------------------------------------------- 1 | import Modal from '../../components/Modal' 2 | 3 | interface Props { 4 | isOpen: boolean 5 | setIsOpen: (isOpen: boolean) => void 6 | onDismiss?: () => void 7 | deleteIssue: () => void 8 | } 9 | 10 | export default function AboutModal({ isOpen, setIsOpen, onDismiss, deleteIssue }: Props) { 11 | const handleDelete = () => { 12 | setIsOpen(false) 13 | if (onDismiss) onDismiss() 14 | deleteIssue() 15 | } 16 | 17 | return ( 18 | 19 |
Are you sure you want to delete this issue?
20 |
21 | 30 | 33 |
34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /demos/linearite/src/pages/Issue/index.module.css: -------------------------------------------------------------------------------- 1 | @media screen and (min-width: 1400px) { 2 | .root { 3 | min-width: 800px; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /demos/linearite/src/pages/List/IssueList.tsx: -------------------------------------------------------------------------------- 1 | import { useState, type CSSProperties } from "react"; 2 | import AutoSizer from "react-virtualized-auto-sizer"; 3 | import IssueRow from "./IssueRow"; 4 | import { FilterState, Issue } from "../../domain/SchemaType"; 5 | import { useQuery } from "@vlcn.io/materialite-react"; 6 | import { applyFilters, queries } from "../../domain/queries"; 7 | import { db } from "../../domain/db"; 8 | import { DifferenceStream } from "@vlcn.io/materialite"; 9 | import VirtualTable from "./VirtualTable-GrowingLimit"; 10 | 11 | export const ROW_HEIGHT = 36; 12 | export interface IssueListProps { 13 | count: number; 14 | } 15 | 16 | function IssueList({ count }: IssueListProps) { 17 | const [, filterState] = useQuery(() => queries.filters(db), []); 18 | // TODO: we could optimize type & search by forking off from the main query? 19 | 20 | // A bunch of jankery to ensure we do a single render on filter update and stream change. 21 | const [prevFilterState, setPrevFilterState] = useState( 22 | null 23 | ); 24 | let initialStream: null | DifferenceStream = null; 25 | if (prevFilterState !== filterState) { 26 | setPrevFilterState(filterState); 27 | const source = db.issues.getSortedSource(filterState!.orderBy); 28 | initialStream = applyFilters(source.stream, filterState!); 29 | } 30 | const [issueStream, setIssueStream] = useState>( 31 | initialStream! 32 | ); 33 | if (initialStream != null && prevFilterState != null) { 34 | setIssueStream(initialStream); 35 | } 36 | 37 | return ( 38 |
39 | 40 | {({ height, width }: { width: number; height: number }) => ( 41 | 52 | )} 53 | 54 |
55 | ); 56 | } 57 | 58 | function rowRenderer(issue: Issue, style: CSSProperties) { 59 | return ; 60 | } 61 | 62 | export default IssueList; 63 | -------------------------------------------------------------------------------- /demos/linearite/src/pages/List/VirtualTable.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | overflow: auto; 3 | } 4 | -------------------------------------------------------------------------------- /demos/linearite/src/pages/List/index.module.css: -------------------------------------------------------------------------------- 1 | .twoPane { 2 | } 3 | 4 | @media screen and (max-width: 1400px) { 5 | .twoPane { 6 | display: none; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /demos/linearite/src/pages/List/index.tsx: -------------------------------------------------------------------------------- 1 | import TopFilter from "../../components/TopFilter"; 2 | import IssueList from "./IssueList"; 3 | import { useQuery } from "@vlcn.io/materialite-react"; 4 | import { queries } from "../../domain/queries"; 5 | import { db } from "../../domain/db"; 6 | import { Route, Routes, useMatch } from "react-router-dom"; 7 | import Issue from "../../pages/Issue"; 8 | import css from "./index.module.css"; 9 | 10 | function List() { 11 | const [, filterState] = useQuery(() => queries.filters(db), []); 12 | const [, filteredIssuesCount] = useQuery( 13 | () => queries.filteredIssuesCount(db, filterState!), 14 | [filterState] 15 | ); 16 | const match = useMatch("/issue/:id"); 17 | const twoPane = match != null ? css.twoPane : ""; 18 | 19 | return ( 20 | <> 21 |
22 | 23 | 24 |
25 | 26 | } /> 27 | 28 | 29 | ); 30 | } 31 | 32 | export default List; 33 | -------------------------------------------------------------------------------- /demos/linearite/src/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | body { 5 | font-size: 12px; 6 | @apply font-medium text-gray-600; 7 | } 8 | 9 | @font-face { 10 | font-family: 'Inter UI'; 11 | font-style: normal; 12 | font-weight: 400; 13 | font-display: swap; 14 | src: 15 | url('assets/fonts/Inter-UI-Regular.woff2') format('woff2'), 16 | url('assets/fonts/Inter-UI-Regular.woff') format('woff'); 17 | } 18 | 19 | @font-face { 20 | font-family: 'Inter UI'; 21 | font-style: normal; 22 | font-weight: 500; 23 | font-display: swap; 24 | src: 25 | url('assets/fonts/Inter-UI-Medium.woff2') format('woff2'), 26 | url('assets/fonts/Inter-UI-Medium.woff') format('woff'); 27 | } 28 | 29 | @font-face { 30 | font-family: 'Inter UI'; 31 | font-style: normal; 32 | font-weight: 600; 33 | font-display: swap; 34 | src: 35 | url('assets/fonts/Inter-UI-SemiBold.woff2') format('woff2'), 36 | url('assets/fonts/Inter-UI-SemiBold.woff') format('woff'); 37 | } 38 | 39 | @font-face { 40 | font-family: 'Inter UI'; 41 | font-style: normal; 42 | font-weight: 800; 43 | font-display: swap; 44 | src: 45 | url('assets/fonts/Inter-UI-ExtraBold.woff2') format('woff2'), 46 | url('assets/fonts/Inter-UI-ExtraBold.woff') format('woff'); 47 | } 48 | 49 | .modal { 50 | max-width: calc(100vw - 32px); 51 | max-height: calc(100vh - 32px); 52 | } 53 | 54 | .editor ul { 55 | list-style-type: circle; 56 | } 57 | .editor ol { 58 | list-style-type: decimal; 59 | } 60 | 61 | #root, 62 | body, 63 | html { 64 | height: 100%; 65 | } 66 | 67 | .tiptap p.is-editor-empty:first-child::before { 68 | color: #adb5bd; 69 | content: attr(data-placeholder); 70 | float: left; 71 | height: 0; 72 | pointer-events: none; 73 | } 74 | -------------------------------------------------------------------------------- /demos/linearite/src/utils/date.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | 3 | export function formatDate(date?: Date): string { 4 | if (!date) return '' 5 | return dayjs(date).format('D MMM') 6 | } 7 | -------------------------------------------------------------------------------- /demos/linearite/src/utils/notification.tsx: -------------------------------------------------------------------------------- 1 | import { toast } from 'react-toastify' 2 | 3 | export function showWarning(msg: string, title: string = '') { 4 | //TODO: make notification showing from bottom 5 | const content = ( 6 |
7 | {title !== '' && ( 8 |
9 | 10 | 11 | 12 | 13 | 14 | {title} 15 |
16 | )} 17 |
{msg}
18 |
19 | ) 20 | toast(content, { 21 | position: 'bottom-right', 22 | }) 23 | } 24 | 25 | export function showInfo(msg: string, title: string = '') { 26 | //TODO: make notification showing from bottom 27 | const content = ( 28 |
29 | {title !== '' && ( 30 |
31 | 32 | 33 | 34 | 35 | 36 | {title} 37 |
38 | )} 39 |
{msg}
40 |
41 | ) 42 | toast(content, { 43 | position: 'bottom-right', 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /demos/linearite/src/utils/shallowEqual.ts: -------------------------------------------------------------------------------- 1 | const is = Object.is; 2 | const hasOwn = Object.prototype.hasOwnProperty; 3 | export default function shallowEqual(objA: any, objB: any) { 4 | if (is(objA, objB)) return true; 5 | 6 | if ( 7 | typeof objA !== "object" || 8 | objA === null || 9 | typeof objB !== "object" || 10 | objB === null 11 | ) { 12 | return false; 13 | } 14 | 15 | const keysA = Object.keys(objA); 16 | const keysB = Object.keys(objB); 17 | 18 | if (keysA.length !== keysB.length) return false; 19 | 20 | for (let i = 0; i < keysA.length; i++) { 21 | if (!hasOwn.call(objB, keysA[i]) || !is(objA[keysA[i]], objB[keysA[i]])) { 22 | return false; 23 | } 24 | } 25 | 26 | return true; 27 | } 28 | -------------------------------------------------------------------------------- /demos/linearite/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /demos/linearite/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./src/**/*.{js,jsx,ts,tsx}', './public/index.html'], 4 | darkMode: ['class', '[data-theme="dark"]'], 5 | theme: { 6 | screens: { 7 | sm: '640px', 8 | // => @media (min-width: 640px) { ... } 9 | 10 | md: '768px', 11 | // => @media (min-width: 768px) { ... } 12 | 13 | lg: '1024px', 14 | // => @media (min-width: 1024px) { ... } 15 | 16 | xl: '1280px', 17 | // => @media (min-width: 1280px) { ... } 18 | 19 | '2xl': '1536px', 20 | // => @media (min-width: 1536px) { ... } 21 | }, 22 | // color: { 23 | // // gray: colors.trueGray, 24 | // }, 25 | fontFamily: { 26 | sans: [ 27 | 'Inter\\ UI', 28 | 'SF\\ Pro\\ Display', 29 | '-apple-system', 30 | 'BlinkMacSystemFont', 31 | 'Segoe\\ UI', 32 | 'Roboto', 33 | 'Oxygen', 34 | 'Ubuntu', 35 | 'Cantarell', 36 | 'Open\\ Sans', 37 | 'Helvetica\\ Neue', 38 | 'sans-serif', 39 | ], 40 | }, 41 | borderWidth: { 42 | DEFAULT: '1px', 43 | 0: '0', 44 | 2: '2px', 45 | 3: '3px', 46 | 4: '4px', 47 | 6: '6px', 48 | 8: '8px', 49 | }, 50 | extend: { 51 | boxShadow: { 52 | modal: 'rgb(0 0 0 / 9%) 0px 3px 12px', 53 | 'large-modal': 'rgb(0 0 0 / 50%) 0px 16px 70px', 54 | }, 55 | spacing: { 56 | 2.5: '10px', 57 | 4.5: '18px', 58 | 3.5: '14px', 59 | 34: '136px', 60 | 61 | 70: '280px', 62 | 140: '560px', 63 | 100: '400px', 64 | 175: '700px', 65 | 53: '212px', 66 | 90: '360px', 67 | }, 68 | fontSize: { 69 | xxs: '0.5rem', 70 | xs: '0.75rem', // 12px 71 | sm: '0.8125rem', // 13px 72 | md: '0.9357rem', //15px 73 | 14: '0.875rem', 74 | base: '1.0rem', // 16px 75 | }, 76 | zIndex: { 77 | 100: 100, 78 | }, 79 | }, 80 | }, 81 | variants: { 82 | extend: { 83 | backgroundColor: ['checked'], 84 | borderColor: ['checked'], 85 | }, 86 | }, 87 | plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')], 88 | } 89 | -------------------------------------------------------------------------------- /demos/linearite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "types": ["vite/client", "vite-plugin-svgr/client"], 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | // "noUnusedLocals": true, 21 | // "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true 23 | }, 24 | "include": ["src"], 25 | "references": [{ "path": "./tsconfig.node.json" }] 26 | } 27 | -------------------------------------------------------------------------------- /demos/linearite/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /demos/linearite/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import svgr from 'vite-plugin-svgr' 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | optimizeDeps: { 8 | // TODO remove once fixed https://github.com/vitejs/vite/issues/8427 9 | exclude: ["@vlcn.io/crsqlite-wasm"], 10 | include: ['react', 'react-dom'], 11 | }, 12 | server: { 13 | fs: { 14 | strict: false, 15 | } 16 | }, 17 | plugins: [ 18 | react(), 19 | svgr({ 20 | svgrOptions: { 21 | svgo: true, 22 | plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx'], 23 | svgoConfig: { 24 | plugins: ['preset-default', 'removeTitle', 'removeDesc', 'removeDoctype', 'cleanupIds'], 25 | }, 26 | }, 27 | }), 28 | ], 29 | }) 30 | -------------------------------------------------------------------------------- /demos/react/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /demos/react/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # materialite-demo 2 | 3 | ## 0.0.5 4 | 5 | ### Patch Changes 6 | 7 | - fix error where paths that did not ask for a recompute are notified of a recompute 8 | - Updated dependencies 9 | - @vlcn.io/ds-and-algos@3.0.2 10 | - @vlcn.io/materialite@3.0.2 11 | - @vlcn.io/materialite-react@2.0.2 12 | 13 | ## 0.0.4 14 | 15 | ### Patch Changes 16 | 17 | - prevent tearing by emulating useEffect via useState 18 | - Updated dependencies 19 | - @vlcn.io/ds-and-algos@3.0.1 20 | - @vlcn.io/materialite@3.0.1 21 | - @vlcn.io/materialite-react@2.0.1 22 | 23 | ## 0.0.3 24 | 25 | ### Patch Changes 26 | 27 | - Updated dependencies 28 | - @vlcn.io/ds-and-algos@3.0.0 29 | - @vlcn.io/materialite@3.0.0 30 | - @vlcn.io/materialite-react@2.0.0 31 | 32 | ## 0.0.2 33 | 34 | ### Patch Changes 35 | 36 | - Updated dependencies 37 | - @vlcn.io/ds-and-algos@2.0.0 38 | - @vlcn.io/materialite@2.0.0 39 | 40 | ## 0.0.1 41 | 42 | ### Patch Changes 43 | 44 | - Updated dependencies 45 | - @vlcn.io/ds-and-algos@1.0.1 46 | - @vlcn.io/materialite@1.0.1 47 | -------------------------------------------------------------------------------- /demos/react/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | parserOptions: { 18 | ecmaVersion: 'latest', 19 | sourceType: 'module', 20 | project: ['./tsconfig.json', './tsconfig.node.json'], 21 | tsconfigRootDir: __dirname, 22 | }, 23 | ``` 24 | 25 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 26 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 27 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 28 | -------------------------------------------------------------------------------- /demos/react/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Vite + React + TS 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /demos/react/notes.md: -------------------------------------------------------------------------------- 1 | - narrow types from filter 2 | - leak checker / operator graph viewer 3 | - useNewView lint? 4 | - useQuery instead of useNewView? query <=> view 5 | - unmaterialized variant of useQuery? 6 | - or update virtual table to take materialized variant? 7 | - distinct count 8 | - // TODO: re-order view vs value return 9 | - hoist comparator up the chain and materialize using source comparator as default comparator if it still exists 10 | - better destructing... TaskTable2 for example. 11 | - `.value` rather than `materializeValue`? 12 | - `useQuery` rather than `useNewView`? Materializes to default structure? 13 | - BUG: dissassociation from data and queries?? 14 | -------------------------------------------------------------------------------- /demos/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "materialite-demo", 3 | "private": true, 4 | "version": "0.0.5", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@vlcn.io/ds-and-algos": "workspace:*", 14 | "@vlcn.io/materialite": "workspace:*", 15 | "@vlcn.io/materialite-react": "workspace:*", 16 | "react": "^18.2.0", 17 | "react-dom": "^18.2.0", 18 | "react-virtualized-auto-sizer": "^1.0.20" 19 | }, 20 | "devDependencies": { 21 | "@types/react": "^18.2.15", 22 | "@types/react-dom": "^18.2.7", 23 | "@types/react-virtualized-auto-sizer": "^1.0.2", 24 | "@typescript-eslint/eslint-plugin": "^6.0.0", 25 | "@typescript-eslint/parser": "^6.0.0", 26 | "@vitejs/plugin-react": "^4.0.3", 27 | "autoprefixer": "^10.4.16", 28 | "eslint": "^8.45.0", 29 | "eslint-plugin-react-hooks": "^4.6.0", 30 | "eslint-plugin-react-refresh": "^0.4.3", 31 | "postcss": "^8.4.31", 32 | "tailwindcss": "^3.3.3", 33 | "typescript": "^5.0.2", 34 | "vite": "^4.4.5" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /demos/react/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /demos/react/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demos/react/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /demos/react/src/TaskApp.tsx: -------------------------------------------------------------------------------- 1 | import { TaskComponent } from "./TaskComponent.js"; 2 | import { Task } from "./data/schema.js"; 3 | import { TaskFilter } from "./TaskFilter.js"; 4 | import { TaskTable } from "./TaskTable.js"; 5 | import { Selected, db } from "./data/DB.js"; 6 | import { useQuery } from "@vlcn.io/materialite-react"; 7 | 8 | export default function TaskApp() { 9 | const [, selectedTask] = useQuery( 10 | () => 11 | db.appState.stream 12 | .filter((s): s is Selected => s._tag === "selected") 13 | .materializeValue(null), 14 | [] 15 | ); 16 | 17 | function onTaskSelected(task: Task) { 18 | db.tx(() => { 19 | if (selectedTask) { 20 | db.appState.delete({ _tag: "selected", id: selectedTask.id }); 21 | } 22 | 23 | db.appState.add({ _tag: "selected", id: task.id }); 24 | }); 25 | } 26 | 27 | return ( 28 |
29 |
30 | 31 | 35 |
36 |
37 | {selectedTask ? ( 38 | 39 | ) : ( 40 |
Select a task to view details
41 | )} 42 |
43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /demos/react/src/data/DB.ts: -------------------------------------------------------------------------------- 1 | import { Materialite } from "@vlcn.io/materialite"; 2 | import { Comment, Task } from "./schema"; 3 | import { createTasks } from "./createTasks"; 4 | 5 | const m = new Materialite(); 6 | export const taskComparator = (l: Task, r: Task) => l.id - r.id; 7 | export const commentComparator = (l: Comment, r: Comment) => { 8 | let comp = l.taskId - r.taskId; 9 | if (comp !== 0) return comp; 10 | comp = l.created - r.created; 11 | if (comp !== 0) return comp; 12 | return l.id - r.id; 13 | }; 14 | 15 | export type Filter = { 16 | _tag: "filter"; 17 | key: keyof Task; 18 | value: string; 19 | }; 20 | export type Selected = { 21 | _tag: "selected"; 22 | id: number; 23 | }; 24 | 25 | export type AppState = Filter | Selected; 26 | export const appStateComparator = (l: AppState, r: AppState) => { 27 | let comp = l._tag.localeCompare(r._tag); 28 | if (comp !== 0) return comp; 29 | 30 | switch (l._tag) { 31 | case "filter": 32 | // filters with the same key are removed and replaced with the new one 33 | // hence no comparison on value 34 | return l.key.localeCompare((r as Filter).key); 35 | case "selected": 36 | // we allow for many selected items, hence compare on id 37 | return l.id - (r as Selected).id; 38 | } 39 | }; 40 | 41 | // Unsorted sources? 42 | // And just do sorting as the final thing? 43 | // That would preclude after based paging... 44 | // So our DB needs extra indices. E.g., a comment by issue by date indice. 45 | export const db = { 46 | tasks: m.newSortedSet(taskComparator), 47 | appState: m.newSortedSet(appStateComparator), 48 | comments: m.newSortedSet(commentComparator), 49 | tx: m.tx.bind(m), 50 | }; 51 | 52 | function fillWithSampleData() { 53 | m.tx(() => { 54 | for (const t of createTasks(1_000_000)) { 55 | db.tasks.add(t); 56 | } 57 | }); 58 | } 59 | 60 | fillWithSampleData(); 61 | -------------------------------------------------------------------------------- /demos/react/src/data/schema.ts: -------------------------------------------------------------------------------- 1 | export type Status = "todo" | "in-progress" | "done"; 2 | export type Priority = "low" | "medium" | "high"; 3 | export type Task = { 4 | id: number; 5 | assignee: string; 6 | title: string; 7 | description: string; 8 | dueDate: Date; 9 | status: Status; 10 | priority: Priority; 11 | labels: string[]; 12 | project: string; 13 | selected?: boolean; 14 | }; 15 | export type Comment = { 16 | id: number; 17 | taskId: number; 18 | content: string; 19 | created: number; 20 | }; 21 | -------------------------------------------------------------------------------- /demos/react/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 7 | line-height: 1.5; 8 | font-weight: 400; 9 | 10 | color-scheme: light; 11 | color: rgba(255, 255, 255, 0.87); 12 | background-color: #242424; 13 | 14 | font-synthesis: none; 15 | text-rendering: optimizeLegibility; 16 | -webkit-font-smoothing: antialiased; 17 | -moz-osx-font-smoothing: grayscale; 18 | -webkit-text-size-adjust: 100%; 19 | } 20 | 21 | a { 22 | font-weight: 500; 23 | color: #646cff; 24 | text-decoration: inherit; 25 | } 26 | a:hover { 27 | color: #535bf2; 28 | } 29 | 30 | .table { 31 | border-collapse: collapse; 32 | width: 100%; 33 | } 34 | 35 | body { 36 | margin: 0; 37 | min-width: 320px; 38 | min-height: 100vh; 39 | } 40 | 41 | h1 { 42 | font-size: 3.2em; 43 | line-height: 1.1; 44 | } 45 | 46 | button { 47 | border-radius: 8px; 48 | border: 1px solid transparent; 49 | padding: 0.6em 1.2em; 50 | font-size: 1em; 51 | font-weight: 500; 52 | font-family: inherit; 53 | background-color: #1a1a1a; 54 | cursor: pointer; 55 | transition: border-color 0.25s; 56 | } 57 | button:hover { 58 | border-color: #646cff; 59 | } 60 | button:focus, 61 | button:focus-visible { 62 | outline: 4px auto -webkit-focus-ring-color; 63 | } 64 | 65 | /* @media (prefers-color-scheme: light) { */ 66 | :root { 67 | color: #213547; 68 | background-color: #ffffff; 69 | } 70 | a:hover { 71 | color: #747bff; 72 | } 73 | button { 74 | background-color: #f9f9f9; 75 | } 76 | /* } */ 77 | -------------------------------------------------------------------------------- /demos/react/src/main.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom/client"; 2 | import TaskApp from "./TaskApp.js"; 3 | import "./index.css"; 4 | 5 | ReactDOM.createRoot(document.getElementById("root")!).render(); 6 | -------------------------------------------------------------------------------- /demos/react/src/virtualized/VirtualTable.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | overflow: auto; 3 | } 4 | -------------------------------------------------------------------------------- /demos/react/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /demos/react/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./index.html", 5 | "./react.html", 6 | "./differential.html", 7 | "./src/**/*.{js,ts,jsx,tsx}", 8 | ], 9 | theme: { 10 | extend: {}, 11 | }, 12 | plugins: [], 13 | }; 14 | -------------------------------------------------------------------------------- /demos/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /demos/react/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /demos/react/walkthrough/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlcn-io/materialite/e08c1fa176883a51296d7c2c3a86c539c0e72eab/demos/react/walkthrough/app.png -------------------------------------------------------------------------------- /demos/react/walkthrough/filter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlcn-io/materialite/e08c1fa176883a51296d7c2c3a86c539c0e72eab/demos/react/walkthrough/filter.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "version": "0.0.0", 4 | "packageManager": "pnpm@7.9.0", 5 | "engines": { 6 | "node": ">=19", 7 | "pnpm": ">=7" 8 | }, 9 | "scripts": { 10 | "test": "vitest" 11 | }, 12 | "dependencies": { 13 | "@changesets/cli": "^2.26.1" 14 | }, 15 | "devDependencies": { 16 | "vitest": "^0.34.6" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/ds-and-algos/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @vlcn.io/ds-and-algos 2 | 3 | ## 3.0.2 4 | 5 | ### Patch Changes 6 | 7 | - fix error where paths that did not ask for a recompute are notified of a recompute 8 | 9 | ## 3.0.1 10 | 11 | ### Patch Changes 12 | 13 | - prevent tearing by emulating useEffect via useState 14 | 15 | ## 3.0.0 16 | 17 | ### Major Changes 18 | 19 | - auto-pull old values on attaching a view, after, take initial impls, react hooks 20 | 21 | ## 2.0.0 22 | 23 | ### Major Changes 24 | 25 | - Rename view materialization methods, add new source and sink types 26 | 27 | ## 1.0.1 28 | 29 | ### Patch Changes 30 | 31 | - first release 32 | -------------------------------------------------------------------------------- /packages/ds-and-algos/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vlcn.io/ds-and-algos", 3 | "version": "3.0.2", 4 | "module": "true", 5 | "type": "module", 6 | "description": "", 7 | "exports": { 8 | "./RedBlackTree": "./dist/trees/RedBlackTree.js", 9 | "./RedBlackMap": "./dist/trees/RedBlackMap.js", 10 | "./tuple": "./dist/tuple.js", 11 | "./objectTracking": "./dist/objectTracking.js", 12 | "./TuplableMap": "./dist/TuplableMap.js", 13 | "./shuffle": "./dist/shuffle.js", 14 | "./binarySearch": "./dist/binarySearch.js", 15 | "./minBy": "./dist/minBy.js", 16 | "./PersistentTreap": "./dist/trees/PersistentTreap.js", 17 | "./Treap": "./dist/trees/Treap.js", 18 | "./types": "./dist/types.js" 19 | }, 20 | "scripts": { 21 | "test": "vitest" 22 | }, 23 | "keywords": [], 24 | "author": "", 25 | "license": "ISC", 26 | "devDependencies": { 27 | "fast-check": "^3.13.1", 28 | "immutable": "5.0.0-beta.4", 29 | "vitest": "^0.34.6" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/ds-and-algos/src/Error.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 One Law LLC 2 | 3 | export class ConcurrentModificationException extends Error {} 4 | -------------------------------------------------------------------------------- /packages/ds-and-algos/src/TuplableMap.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 One Law LLC 2 | 3 | import { objectId } from "./objectTracking.js"; 4 | import { isTuple } from "./tuple.js"; 5 | 6 | /** 7 | * A regular JavaScript map but it can have tuples of arbitrary things for keys. 8 | * 9 | * Hmm... we might not need this anymore. 10 | * Given we've changed to having all operators take `Key` functions. 11 | */ 12 | export class TuplableMap implements Map { 13 | #map: Map; 14 | 15 | constructor() { 16 | this.#map = new Map(); 17 | } 18 | 19 | get size() { 20 | return this.#map.size; 21 | } 22 | 23 | clear() { 24 | this.#map.clear(); 25 | } 26 | 27 | delete(key: K) { 28 | return this.#map.delete(toKey(key)); 29 | } 30 | 31 | *entries() { 32 | for (const [_, entry] of this.#map.entries()) { 33 | yield entry; 34 | } 35 | } 36 | 37 | *[Symbol.iterator](): IterableIterator<[K, V]> { 38 | for (const [_, entry] of this.#map) { 39 | yield entry; 40 | } 41 | } 42 | 43 | forEach( 44 | callbackfn: (value: V, key: K, map: Map) => void, 45 | thisArg?: any 46 | ) { 47 | for (const [key, value] of this) { 48 | callbackfn.call(thisArg, value, key, this); 49 | } 50 | } 51 | 52 | get(key: K) { 53 | const res = this.#map.get(toKey(key)); 54 | return res == null ? undefined : res[1]; 55 | } 56 | 57 | getWithDefault(key: K, def: V) { 58 | const k = toKey(key); 59 | const res = this.#map.get(k); 60 | if (res === undefined) { 61 | this.#map.set(k, [key, def]); 62 | } 63 | return res == null ? def : res[1]; 64 | } 65 | 66 | has(key: K) { 67 | return this.#map.has(toKey(key)); 68 | } 69 | 70 | *keys() { 71 | for (const [key, _value] of this) { 72 | yield key; 73 | } 74 | } 75 | 76 | set(key: K, value: V) { 77 | this.#map.set(toKey(key), [key, value]); 78 | return this; 79 | } 80 | 81 | *values() { 82 | for (const [_key, value] of this) { 83 | yield value; 84 | } 85 | } 86 | 87 | [Symbol.toStringTag]: string = "TuplableMap"; 88 | } 89 | 90 | function toKey(k: any): any { 91 | // if it is a tuple, recursively convert each entry 92 | if (isTuple(k)) { 93 | return k.map(toKey).join("\uE0FA"); 94 | } 95 | if (typeof k === "object") { 96 | return "\uE0FB" + objectId(k); 97 | } 98 | 99 | if (k === null) { 100 | return "\uE0FC"; 101 | } else if (k === undefined) { 102 | return "\uE0FD"; 103 | } 104 | 105 | return k.toString(); 106 | } 107 | -------------------------------------------------------------------------------- /packages/ds-and-algos/src/binarySearch.ts: -------------------------------------------------------------------------------- 1 | import { Comparator } from "./types.js"; 2 | 3 | export function binarySearch( 4 | arr: readonly T[], 5 | el: T, 6 | comparator: Comparator 7 | ) { 8 | let m = 0; 9 | let n = arr.length - 1; 10 | while (m <= n) { 11 | let k = (n + m) >> 1; 12 | let cmp = comparator(el, arr[k]!); 13 | if (cmp > 0) { 14 | m = k + 1; 15 | } else if (cmp < 0) { 16 | n = k - 1; 17 | } else { 18 | return k; 19 | } 20 | } 21 | return ~m; 22 | } 23 | -------------------------------------------------------------------------------- /packages/ds-and-algos/src/minBy.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 One Law LLC 2 | 3 | import { Primitive } from "./types.js"; 4 | 5 | export function minBy(x: Iterable, f: (x: T) => Primitive) { 6 | let min: Primitive | undefined = undefined; 7 | let minVal: T | undefined = undefined; 8 | for (const val of x) { 9 | const v = f(val); 10 | if (min === undefined) { 11 | min = v; 12 | minVal = val; 13 | } else if (v < min) { 14 | min = v; 15 | minVal = val; 16 | } 17 | } 18 | return minVal; 19 | } 20 | -------------------------------------------------------------------------------- /packages/ds-and-algos/src/objectTracking.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 One Law LLC 2 | 3 | const TAG = Symbol("tag"); 4 | // TODO: alternate between high and low ids? 5 | let id = 0; 6 | 7 | export function objectId(v: Object) { 8 | if ((v as any)[TAG] === undefined) { 9 | Object.defineProperty(v, TAG, { 10 | value: ++id, 11 | writable: false, 12 | enumerable: false, 13 | }); 14 | } 15 | 16 | return (v as any)[TAG]; 17 | } 18 | -------------------------------------------------------------------------------- /packages/ds-and-algos/src/scratch.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 One Law LLC 2 | 3 | import { PersistentTreap } from "./trees/PersistentTreap.js"; 4 | 5 | // Repeatedly remove and add from our treap 6 | // and test `getByIndex` has no missing indices. 7 | 8 | const SIZE = 1000; // example size 9 | let treap = new PersistentTreap((a, b) => a - b); 10 | 11 | for (let i = 0; i < SIZE; ++i) { 12 | treap = treap.add(i); 13 | } 14 | 15 | const TRIALS = 4; 16 | for (let i = 0; i < SIZE; ++i) { 17 | for (let t = 0; t < TRIALS; ++t) { 18 | treap = treap.delete(i); 19 | treap = treap.add(i); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/ds-and-algos/src/shuffle.ts: -------------------------------------------------------------------------------- 1 | export function shuffle(array: V[]) { 2 | let currentIndex = array.length; 3 | let randomIndex = 0; 4 | 5 | // While there remain elements to shuffle. 6 | while (currentIndex > 0) { 7 | // Pick a remaining element. 8 | randomIndex = Math.floor(Math.random() * currentIndex); 9 | currentIndex--; 10 | 11 | // And swap it with the current element. 12 | [array[currentIndex], array[randomIndex]] = [ 13 | array[randomIndex]!, 14 | array[currentIndex]!, 15 | ]; 16 | } 17 | 18 | return array; 19 | } 20 | -------------------------------------------------------------------------------- /packages/ds-and-algos/src/trees-v2/error.ts: -------------------------------------------------------------------------------- 1 | export class ConcurrentModificationException extends Error {} 2 | -------------------------------------------------------------------------------- /packages/ds-and-algos/src/trees-v2/types.ts: -------------------------------------------------------------------------------- 1 | export type Primitive = string | number | boolean | bigint; 2 | export type Comparator = (l: T, r: T) => number; 3 | 4 | export class Node implements INode { 5 | size: number = 1; 6 | constructor( 7 | public value: T, 8 | public priority: number, 9 | public left?: Node | undefined, 10 | public right?: Node | undefined, 11 | ) {} 12 | 13 | getChild(isRight: boolean): Node | undefined { 14 | return isRight ? this.right : this.left; 15 | } 16 | } 17 | 18 | export interface INode { 19 | value: T; 20 | left?: INode | undefined; 21 | right?: INode | undefined; 22 | getChild(isRight: boolean): INode | undefined; 23 | } 24 | 25 | export interface ITree { 26 | readonly size: number; 27 | readonly root: Node | undefined; 28 | readonly version: number; 29 | 30 | add(value: T): ITree; 31 | delete(value: T): ITree; 32 | clear(): ITree; 33 | map(callback: (value: T) => U): U[]; 34 | filter(predicate: (value: T) => boolean): T[]; 35 | contains(value: T): boolean; 36 | reduce(callback: (accumulator: U, value: T) => U, initialValue: U): U; 37 | toArray(): T[]; 38 | at(index: number): T | undefined; 39 | get(value: T): T | undefined; 40 | getMin(): T | undefined; 41 | getMax(): T | undefined; 42 | iteratorAfter(value: T): IterableIterator; 43 | [Symbol.iterator](): Generator; 44 | } 45 | -------------------------------------------------------------------------------- /packages/ds-and-algos/src/trees/RedBlackMap.ts: -------------------------------------------------------------------------------- 1 | import { RBTree } from "./RedBlackTree.js"; 2 | 3 | // TODO: an implicit contract of JS maps is that things come out in insertion order... 4 | // We could keep track of the insertion order of items for iteration... 5 | // this could also solve the concurrent modification problem. 6 | // Would speed up un-ordered iteration 7 | export class RBMap implements Map { 8 | readonly #tree; 9 | 10 | constructor(comparator: (a: K, b: K) => number) { 11 | this.#tree = new RBTree<[K, V]>((a: [K, V], b: [K, V]) => { 12 | return comparator(a[0], b[0]); 13 | }); 14 | } 15 | 16 | get size() { 17 | return this.#tree.size; 18 | } 19 | 20 | clear() { 21 | this.#tree.clear(); 22 | } 23 | 24 | delete(key: K) { 25 | return this.#tree.remove([key, undefined as any]); 26 | } 27 | 28 | entries() { 29 | return this.#tree.iterator(); 30 | } 31 | 32 | [Symbol.iterator](): IterableIterator<[K, V]> { 33 | return this.#tree.iterator(); 34 | } 35 | 36 | forEach( 37 | callbackfn: (value: V, key: K, map: Map) => void, 38 | thisArg?: any 39 | ) { 40 | for (const [key, value] of this) { 41 | callbackfn.call(thisArg, value, key, this); 42 | } 43 | } 44 | 45 | get(key: K) { 46 | const res = this.#tree.find([key, undefined as any]); 47 | return res == null ? undefined : res[1]; 48 | } 49 | 50 | getWithDefault(key: K, def: V) { 51 | const res = this.#tree.find([key, undefined as any]); 52 | return res == null ? def : res[1]; 53 | } 54 | 55 | has(key: K) { 56 | return this.#tree.find([key, undefined as any]) !== undefined; 57 | } 58 | 59 | *keys() { 60 | for (const [key, _value] of this) { 61 | yield key; 62 | } 63 | } 64 | 65 | set(key: K, value: V) { 66 | this.#tree.insert([key, value]); 67 | return this; 68 | } 69 | 70 | *values() { 71 | for (const [_key, value] of this) { 72 | yield value; 73 | } 74 | } 75 | 76 | [Symbol.toStringTag]: "RedBlackMap"; 77 | } 78 | -------------------------------------------------------------------------------- /packages/ds-and-algos/src/trees/__tests__/ImmVsTreap.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "vitest"; 2 | import { Map } from "immutable"; 3 | import { PersistentTreap } from "../PersistentTreap.js"; 4 | 5 | test("should add values correctly", () => { 6 | const SIZE = 100_000; 7 | const TRIALS = 10; 8 | 9 | let average = 0; 10 | for (let t = 0; t < TRIALS; ++t) { 11 | let treap = new PersistentTreap((a, b) => a - b); 12 | const start = Date.now(); 13 | for (let i = 0; i < SIZE; i++) { 14 | treap = treap.add(i); 15 | } 16 | const end = Date.now(); 17 | average += end - start; 18 | } 19 | 20 | console.log(`PersistentTreap: ${average / TRIALS}ms`); 21 | 22 | average = 0; 23 | for (let t = 0; t < TRIALS; ++t) { 24 | let map = Map(); 25 | const start = Date.now(); 26 | for (let i = 0; i < SIZE; i++) { 27 | map = map.set(i, i); 28 | } 29 | const end = Date.now(); 30 | average += end - start; 31 | } 32 | 33 | console.log(`Immutable Map: ${average / TRIALS}ms`); 34 | // Conclusion: about the same speed. Treap slightly faster. 35 | }); 36 | -------------------------------------------------------------------------------- /packages/ds-and-algos/src/trees/__tests__/RedBlackTreePerf.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "vitest"; 2 | import { RBTree } from "../RedBlackTree.js"; 3 | 4 | test("time a regular map vs an rbtree", () => { 5 | const N = 1_000_000; 6 | const map = new Map(); 7 | const rbtree = new RBTree((a, b) => a - b); 8 | 9 | const start = performance.now(); 10 | for (let i = 0; i < N; i++) { 11 | // const v = (Math.random() * N) | 0; 12 | // map.set(v, v); 13 | map.set(i, i); 14 | } 15 | const mapTime = performance.now() - start; 16 | 17 | const start2 = performance.now(); 18 | for (let i = 0; i < N; i++) { 19 | // rbtree.insert((Math.random() * N) | 0); 20 | rbtree.insert(i); 21 | } 22 | const rbtreeTime = performance.now() - start2; 23 | 24 | console.log(`Map: ${mapTime}ms, RBTree: ${rbtreeTime}ms`); 25 | }); 26 | -------------------------------------------------------------------------------- /packages/ds-and-algos/src/trees/__tests__/Treap.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { Treap } from "../Treap.js"; 3 | 4 | // See TreapFastCheck for more intensive tests 5 | 6 | test("getting an iterator", () => { 7 | const t = new Treap((a, b) => a - b); 8 | t.add(10); 9 | t.add(5); 10 | t.add(15); 11 | t.add(2); 12 | expect([...t]).toEqual([2, 5, 10, 15]); 13 | const iter = t.iteratorAfter(6); 14 | expect(iter.next().value).toBe(10); 15 | expect(iter.next().value).toBe(15); 16 | expect(iter.next().value).toBe(null); 17 | }); 18 | 19 | test("delete", () => { 20 | const t = new Treap((a, b) => a - b); 21 | t.add(0); 22 | t.add(0); 23 | t.delete(0); 24 | 25 | console.log([...t]); 26 | expect(t.size).toBe(0); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/ds-and-algos/src/trees/__tests__/scratch.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { PersistentTreap } from "../PersistentTreap.js"; 3 | // [[["insert",-29],["reinsert",0],["insert",-15],["delete",-3]]] 4 | 5 | test("", () => { 6 | let tree = new PersistentTreap((l, r) => l - r); 7 | 8 | let oldTree = tree; 9 | let oldValues = [...oldTree]; 10 | tree = tree.add(-29); 11 | expect(oldValues).toEqual([...oldTree]); 12 | 13 | oldTree = tree; 14 | oldValues = [...oldTree]; 15 | tree = tree.add(0); 16 | expect(oldValues).toEqual([...oldTree]); 17 | 18 | oldTree = tree; 19 | oldValues = [...oldTree]; 20 | tree = tree.add(0); 21 | expect(oldValues).toEqual([...oldTree]); 22 | 23 | oldTree = tree; 24 | oldValues = [...oldTree]; 25 | tree = tree.add(-15); 26 | expect(oldValues).toEqual([...oldTree]); 27 | 28 | oldTree = tree; 29 | oldValues = [...oldTree]; 30 | // tree = tree.delete(); 31 | expect(oldValues).toEqual([...oldTree]); 32 | 33 | console.log([...tree], tree.size); 34 | 35 | // TODO: test replace too 36 | }); 37 | -------------------------------------------------------------------------------- /packages/ds-and-algos/src/tuple.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 One Law LLC 2 | 3 | const tupleSymbol = Symbol("tupleSymbol"); 4 | const joinResultSymbol = Symbol("joinResultSymbol"); 5 | 6 | export function makeTuple(x: T): T { 7 | Object.defineProperty(x, tupleSymbol, { 8 | value: true, 9 | writable: false, 10 | enumerable: false, 11 | }); 12 | return x; 13 | } 14 | 15 | export function makeTuple2(x: [T1, T2]): Tuple2 { 16 | Object.defineProperty(x, tupleSymbol, { 17 | value: true, 18 | writable: false, 19 | enumerable: false, 20 | }); 21 | return x as any; 22 | } 23 | 24 | export function isTuple(x: T): boolean { 25 | return Array.isArray(x) && (x as any)[tupleSymbol] === true; 26 | } 27 | 28 | export type Tuple2 = readonly [T1, T2] & { [tupleSymbol]: true }; 29 | export type Tuple = readonly T[] & { [tupleSymbol]: true }; 30 | export type TupleVariadic = [...T] & { 31 | [tupleSymbol]: true; 32 | }; 33 | export type JoinResult = readonly T[] & { 34 | [joinResultSymbol]: true; 35 | [tupleSymbol]: true; 36 | }; 37 | export type JoinResultVariadic = readonly [...T] & { 38 | [joinResultSymbol]: true; 39 | [tupleSymbol]: true; 40 | }; 41 | 42 | export function joinResult( 43 | x: T 44 | ): JoinResultVariadic { 45 | Object.defineProperty(x, tupleSymbol, { 46 | value: true, 47 | writable: false, 48 | enumerable: false, 49 | }); 50 | Object.defineProperty(x, joinResultSymbol, { 51 | value: true, 52 | writable: false, 53 | enumerable: false, 54 | }); 55 | return x as any; 56 | } 57 | 58 | export function isJoinResult(x: T): boolean { 59 | return Array.isArray(x) && (x as any)[joinResultSymbol] === true; 60 | } 61 | -------------------------------------------------------------------------------- /packages/ds-and-algos/src/types.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 One Law LLC 2 | 3 | export type Primitive = string | number | boolean | bigint; 4 | export type Comparator = (l: T, r: T) => number; 5 | 6 | export class Node implements INode { 7 | size: number = 1; 8 | constructor( 9 | public value: T, 10 | public priority: number, 11 | public left: Node | null = null, 12 | public right: Node | null = null 13 | ) {} 14 | 15 | getChild(isRight: boolean): Node | null { 16 | return isRight ? this.right : this.left; 17 | } 18 | } 19 | 20 | export interface INode { 21 | value: T; 22 | left: INode | null; 23 | right: INode | null; 24 | getChild(isRight: boolean): INode | null; 25 | } 26 | 27 | export interface ITree { 28 | readonly size: number; 29 | readonly _root: Node | null; 30 | readonly version: number; 31 | 32 | add(value: T): ITree; 33 | delete(value: T): ITree; 34 | clear(): ITree; 35 | map(callback: (value: T) => U): U[]; 36 | filter(predicate: (value: T) => boolean): T[]; 37 | contains(value: T): boolean; 38 | reduce(callback: (accumulator: U, value: T) => U, initialValue: U): U; 39 | toArray(): T[]; 40 | at(index: number): T | null; 41 | get(value: T): T | null; 42 | getMin(): T | null; 43 | getMax(): T | null; 44 | iteratorAfter(value: T): IterableIterator; 45 | [Symbol.iterator](): Generator; 46 | } 47 | -------------------------------------------------------------------------------- /packages/ds-and-algos/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // project options 4 | "lib": ["ESNext", "dom"], // specifies which default set of type definitions to use ("DOM", "ES6", etc) 5 | "outDir": "dist", // .js (as well as .d.ts, .js.map, etc.) files will be emitted into this directory., 6 | "rootDir": "./src", 7 | "removeComments": true, // Strips all comments from TypeScript files when converting into JavaScript- you rarely read compiled code so this saves space 8 | "target": "ES6", // Target environment. Most modern browsers support ES6, but you may want to set it to newer or older. (defaults to ES3) 9 | 10 | // Module resolution 11 | "baseUrl": "./src", // Lets you set a base directory to resolve non-absolute module names. 12 | "esModuleInterop": true, // fixes some issues TS originally had with the ES6 spec where TypeScript treats CommonJS/AMD/UMD modules similar to ES6 module 13 | "moduleResolution": "NodeNext", // Pretty much always node for modern JS. Other option is "classic" 14 | "paths": {}, // A series of entries which re-map imports to lookup locations relative to the baseUrl 15 | "module": "NodeNext", 16 | "skipLibCheck": true, 17 | 18 | // Source Map 19 | "sourceMap": true, // enables the use of source maps for debuggers and error reporting etc 20 | 21 | // Strict Checks 22 | "alwaysStrict": true, // Ensures that your files are parsed in the ECMAScript strict mode, and emit “use strict” for each source file. 23 | "allowUnreachableCode": false, // pick up dead code paths 24 | "noImplicitAny": true, // In some cases where no type annotations are present, TypeScript will fall back to a type of any for a variable when it cannot infer the type. 25 | "strictNullChecks": true, // When strictNullChecks is true, null and undefined have their own distinct types and you’ll get a type error if you try to use them where a concrete value is expected. 26 | 27 | // Linter Checks 28 | "noImplicitReturns": true, 29 | "noUncheckedIndexedAccess": true, // accessing index must always check for undefined 30 | "composite": true, 31 | "incremental": true 32 | }, 33 | "include": ["./src"] 34 | } 35 | -------------------------------------------------------------------------------- /packages/materialite/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @vlcn.io/materialite 2 | 3 | ## 3.0.2 4 | 5 | ### Patch Changes 6 | 7 | - fix error where paths that did not ask for a recompute are notified of a recompute 8 | - Updated dependencies 9 | - @vlcn.io/ds-and-algos@3.0.2 10 | 11 | ## 3.0.1 12 | 13 | ### Patch Changes 14 | 15 | - prevent tearing by emulating useEffect via useState 16 | - Updated dependencies 17 | - @vlcn.io/ds-and-algos@3.0.1 18 | 19 | ## 3.0.0 20 | 21 | ### Major Changes 22 | 23 | - auto-pull old values on attaching a view, after, take initial impls, react hooks 24 | 25 | ### Patch Changes 26 | 27 | - Updated dependencies 28 | - @vlcn.io/ds-and-algos@3.0.0 29 | 30 | ## 2.0.0 31 | 32 | ### Major Changes 33 | 34 | - Rename view materialization methods, add new source and sink types 35 | 36 | ### Patch Changes 37 | 38 | - Updated dependencies 39 | - @vlcn.io/ds-and-algos@2.0.0 40 | 41 | ## 1.0.1 42 | 43 | ### Patch Changes 44 | 45 | - first release 46 | - Updated dependencies 47 | - @vlcn.io/ds-and-algos@1.0.1 48 | -------------------------------------------------------------------------------- /packages/materialite/docs/graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlcn-io/materialite/e08c1fa176883a51296d7c2c3a86c539c0e72eab/packages/materialite/docs/graph.png -------------------------------------------------------------------------------- /packages/materialite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vlcn.io/materialite", 3 | "version": "3.0.2", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "type": "module", 7 | "exports": { 8 | ".": "./dist/index.js" 9 | }, 10 | "module": "true", 11 | "scripts": { 12 | "test": "vitest" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "devDependencies": { 18 | "typescript": "^5.2.2", 19 | "vitest": "^0.34.6" 20 | }, 21 | "dependencies": { 22 | "@vlcn.io/ds-and-algos": "workspace:*" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/materialite/src/__tests__/after.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { Materialite } from "../materialite.js"; 3 | 4 | test("after on a stateful source. After is pushed to the source.", () => { 5 | const m = new Materialite(); 6 | let comparisonCount = 0; 7 | const comp = (l: number, r: number) => { 8 | comparisonCount++; 9 | return l - r; 10 | }; 11 | const otherComp = (l: number, r: number) => { 12 | comparisonCount++; 13 | return l - r; 14 | }; 15 | const s = m.newSortedSet(comp); 16 | 17 | m.tx(() => { 18 | for (let i = 0; i < 10_000; ++i) { 19 | s.add(i); 20 | } 21 | }); 22 | 23 | console.log(comparisonCount); 24 | const originalCompareCount = comparisonCount; 25 | 26 | // TODO: we can exclude the requirement of a comparator 27 | // when we're chained off of sorted sets.... 28 | let effectRuns = 0; 29 | const collection: number[] = []; 30 | s.stream.after(9_900, comp).effect((v) => { 31 | ++effectRuns; 32 | collection.push(v); 33 | }); 34 | 35 | expect(comparisonCount - originalCompareCount).toBeLessThan(200); 36 | expect(effectRuns).toBe(99); 37 | const expected: number[] = []; 38 | for (let i = 9901; i < 10_000; ++i) { 39 | expected.push(i); 40 | } 41 | expect(collection).toEqual(expected); 42 | 43 | s.stream.after(9_900, otherComp).effect((_v) => {}); 44 | console.log(comparisonCount); 45 | // If we don't use the same comparator we don't know we can use 46 | // the source set ordering for `after` and thus we have to sort the entire set. 47 | expect(comparisonCount - originalCompareCount).toBeGreaterThan(10_000); 48 | 49 | // add to the set 50 | // create a pipeline with after 51 | // test that the after is pushed to the source (how do dis?) 52 | }); 53 | 54 | // test materialization of after has the right values 55 | 56 | // test hoisting .. that after isn't hoisted too deeply down the query path. if it is it can break branches that 57 | // did not request after 58 | -------------------------------------------------------------------------------- /packages/materialite/src/__tests__/cleanup.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, vi } from "vitest"; 2 | import { Materialite } from "../materialite.js"; 3 | 4 | test("cleaning up the only user of a stream cleans up the entire pipeline", () => { 5 | vi.useFakeTimers(); 6 | const materialite = new Materialite(); 7 | const set = materialite.newStatelessSet(); 8 | 9 | let notifyCount = 0; 10 | const final = set.stream 11 | .effect((_) => notifyCount++) 12 | .effect((_) => notifyCount++) 13 | .effect((_) => notifyCount++); 14 | 15 | set.add(1); 16 | expect(notifyCount).toBe(3); 17 | final.destroy(); 18 | vi.runAllTimers(); 19 | set.add(2); 20 | // stream was cleaned up, all the way to the root 21 | // so no more notifications. 22 | expect(notifyCount).toBe(3); 23 | }); 24 | 25 | test("cleaning up the only user of a stream cleans up the entire pipeline but stops at a used fork", () => { 26 | vi.useFakeTimers(); 27 | const materialite = new Materialite(); 28 | const set = materialite.newStatelessSet(); 29 | 30 | let notifyCount = 0; 31 | const stream1 = set.stream.effect((_) => notifyCount++); 32 | const stream2 = stream1.effect((_) => notifyCount++); 33 | const stream3 = stream1.effect((_) => notifyCount++); 34 | // Forked stream which creates this graph: 35 | /* 36 | stream1 37 | / \ 38 | stream2 stream3 39 | */ 40 | 41 | set.add(1); 42 | expect(notifyCount).toBe(3); 43 | stream3.destroy(); 44 | vi.runAllTimers(); 45 | set.add(2); 46 | // stream was cleaned up to fork, so still 2 notification 47 | expect(notifyCount).toBe(5); 48 | stream2.destroy(); 49 | vi.runAllTimers(); 50 | set.add(3); 51 | // stream was cleaned up, all the way to the root 52 | // so no more notifications. 53 | expect(notifyCount).toBe(5); 54 | }); 55 | 56 | // test cleanup when listeners / effects are attached 57 | -------------------------------------------------------------------------------- /packages/materialite/src/__tests__/hoist.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | 3 | test("can hoist operators to the source expression", () => { 4 | // hmm... will this break forking? 5 | // if after is pushed to the source then forked streams will be forced into `after` semantics too. 6 | // We can get more complex to solve this or as a first pass require that `after` must be the first operator 7 | // against the source. 8 | expect(true).toBe(true); 9 | }); 10 | -------------------------------------------------------------------------------- /packages/materialite/src/__tests__/lazy.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "vitest"; 2 | 3 | /** 4 | * Map, filter, etc. are lazy. 5 | * 6 | * As in, if we have a `take` at one end that limits how much 7 | * we want in the view then on the other end (upstreams) we will stop 8 | * processing once that limit is hit. 9 | */ 10 | test("lazy", () => { 11 | console.log("todo"); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/materialite/src/__tests__/mutableSource.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | // import { Materialite } from "../materialite.js"; 3 | 4 | test("mutable treap source", () => { 5 | expect(true).toBe(true); 6 | }); 7 | -------------------------------------------------------------------------------- /packages/materialite/src/__tests__/notification.test.ts: -------------------------------------------------------------------------------- 1 | // Ensure we only notify once per transaction 2 | // Even if a signal or operator has many inputs 3 | // 4 | // Ensure that all inputs are seen at the current version. 5 | import { test } from "vitest"; 6 | 7 | test("todo", () => {}); 8 | -------------------------------------------------------------------------------- /packages/materialite/src/__tests__/signals.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { Materialite } from "../materialite.js"; 3 | 4 | test("Atom emits on change", () => { 5 | const m = new Materialite(); 6 | const a = m.newAtom(1); 7 | 8 | let count = 0; 9 | let value = 0; 10 | a.on((v) => { 11 | count++; 12 | value = v; 13 | }); 14 | 15 | // listeners are not notified until a change 16 | expect(count).toBe(0); 17 | 18 | // notified on change 19 | a.value = 2; 20 | expect(count).toBe(1); 21 | expect(value).toBe(2); 22 | 23 | m.tx(() => { 24 | a.value = 3; 25 | // not notified until tx commit 26 | expect(count).toBe(1); 27 | expect(value).toBe(2); 28 | }); 29 | expect(count).toBe(2); 30 | expect(value).toBe(3); 31 | }); 32 | 33 | test("Thunk can take many inputs", () => { 34 | const m = new Materialite(); 35 | const a = m.newAtom(1); 36 | const b = m.newAtom(2); 37 | const c = m.newAtom(3); 38 | 39 | const t = m.compute((a: number, b: number, c: number) => a + b + c, a, b, c); 40 | 41 | let count = 0; 42 | let value = t.value; 43 | t.on((v) => { 44 | count++; 45 | value = v; 46 | }); 47 | 48 | expect(value).toBe(6); 49 | expect(count).toBe(0); 50 | 51 | m.tx(() => { 52 | a.value = 2; 53 | b.value = 3; 54 | c.value = 4; 55 | expect(value).toBe(6); 56 | expect(count).toBe(0); 57 | }); 58 | expect(value).toBe(9); 59 | expect(count).toBe(1); 60 | }); 61 | 62 | test("no notifications if values do not change", () => { 63 | const m = new Materialite(); 64 | const a = m.newAtom(1); 65 | 66 | let count = 0; 67 | let value = a.value; 68 | a.on((v) => { 69 | count++; 70 | value = v; 71 | }); 72 | 73 | a.value = 1; 74 | expect(count).toBe(0); 75 | expect(value).toBe(1); 76 | }); 77 | 78 | test("eager cleanup", () => { 79 | const m = new Materialite(); 80 | const a = m.newAtom(1); 81 | 82 | let count = 0; 83 | const fn = (v: number) => { 84 | ++count; 85 | return v + 1; 86 | }; 87 | const final = a.pipe(fn).pipe(fn).pipe(fn); 88 | const off = final.on(() => {}); 89 | 90 | expect(final.value).toBe(4); 91 | expect(count).toBe(3); 92 | 93 | // TODO: off should take options about eager cleanup 94 | off(); 95 | 96 | // TODO: If we don't eager cleanup how do we clean the graph? :| 97 | // Finalization registries? 98 | // Weak refs to things in theh graph? hmm.. middle nodes won't be held. 99 | // No clean unless explicit destroy called? 100 | a.value = 2; 101 | expect(count).toBe(3); 102 | 103 | m.compute; 104 | }); 105 | 106 | test("eager cleanup doesn't prune used branaches", () => {}); 107 | 108 | // test thunks / composition of many signals into a single computed value 109 | // test materialized views as signals 110 | // test de-materialization? 111 | // test atoms 112 | -------------------------------------------------------------------------------- /packages/materialite/src/__tests__/sortedSource.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | // import { Materialite } from "../materialite.js"; 3 | 4 | test("we can jump around sorted sources", () => { 5 | expect(true).toBe(true); 6 | }); 7 | -------------------------------------------------------------------------------- /packages/materialite/src/__tests__/stress.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "vitest"; 2 | 3 | // stress test join and reduce operations 4 | // their implementation seem... naive at best. 5 | 6 | // test reduce when all `1`s are sent... so same item is added many times. 7 | 8 | test("", () => {}); 9 | -------------------------------------------------------------------------------- /packages/materialite/src/__tests__/take.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | // import { Materialite } from "../materialite.js"; 3 | 4 | test("take decimates us to a window", () => { 5 | expect(true).toBe(true); 6 | }); 7 | 8 | // take re-fills the window if something drops out 9 | -------------------------------------------------------------------------------- /packages/materialite/src/__tests__/transaction.test.ts: -------------------------------------------------------------------------------- 1 | // effects only run after entire graph has processed a tx 2 | // values don't change at a node? we don't notify downstream 3 | // a signal with many inputs only notifies a single time on tx commit 4 | // a signal with many inputs only runs once rather than once per input 5 | -------------------------------------------------------------------------------- /packages/materialite/src/core/__tests__/consolidation.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { comparator } from "../consolidation.js"; 3 | import { makeTuple } from "@vlcn.io/ds-and-algos/tuple"; 4 | 5 | test("tuple compare", () => { 6 | const t1 = makeTuple([1, 2, 3] as const); 7 | const t2 = makeTuple([1, 2, 3] as const); 8 | 9 | expect(comparator(t1, t2)).toBe(0); 10 | 11 | const t3 = makeTuple([1, 2, 3, 4] as const); 12 | expect(comparator(t1, t3)).toBe(-1); 13 | expect(comparator(t3, t1)).toBe(1); 14 | 15 | const t4 = makeTuple([1, 2, 4] as const); 16 | expect(comparator(t1, t4)).toBe(-1); 17 | expect(comparator(t4, t1)).toBe(1); 18 | 19 | const t5 = makeTuple([1, 2, 2] as const); 20 | expect(comparator(t1, t5)).toBe(1); 21 | expect(comparator(t5, t1)).toBe(-1); 22 | }); 23 | 24 | test("tuple compare nested", () => { 25 | const t1 = makeTuple([1, makeTuple([2, 3])] as const); 26 | const t2 = makeTuple([1, makeTuple([2, 3])] as const); 27 | 28 | expect(comparator(t1, t2)).toBe(0); 29 | 30 | const t3 = makeTuple([1, makeTuple([2, 3, 4])] as const); 31 | expect(comparator(t1, t3)).toBe(-1); 32 | expect(comparator(t3, t1)).toBe(1); 33 | 34 | const t4 = makeTuple([1, makeTuple([2, 4])] as const); 35 | expect(comparator(t1, t4)).toBe(-1); 36 | expect(comparator(t4, t1)).toBe(1); 37 | 38 | const t5 = makeTuple([1, makeTuple([2, 2])] as const); 39 | expect(comparator(t1, t5)).toBe(1); 40 | expect(comparator(t5, t1)).toBe(-1); 41 | }); 42 | 43 | test("object compare", () => { 44 | const o1 = { a: 1, b: 2 }; 45 | expect(comparator(o1, o1)).toBe(0); 46 | const o2 = { a: 1, b: 2 }; 47 | // o2 created second, has greater ID 48 | expect(comparator(o1, o2)).toBe(-1); 49 | expect(comparator(o2, o1)).toBe(1); 50 | }); 51 | 52 | test("array compare", () => { 53 | const a1 = [1, 2, 3]; 54 | expect(comparator(a1, a1)).toBe(0); 55 | const a2 = [1, 2, 3]; 56 | // a2 created second, has greater ID 57 | expect(comparator(a1, a2)).toBe(-1); 58 | expect(comparator(a2, a1)).toBe(1); 59 | }); 60 | 61 | test("mixed compare", () => { 62 | const o1 = { a: 1, b: 2 }; 63 | const a1 = [1, 2, 3]; 64 | expect(comparator(o1, a1)).toBe(-1); 65 | expect(comparator(a1, o1)).toBe(1); 66 | 67 | const bi1 = BigInt(1); 68 | expect(comparator(o1, bi1)).toBe(1); 69 | expect(comparator(bi1, o1)).toBe(-1); 70 | }); 71 | -------------------------------------------------------------------------------- /packages/materialite/src/core/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { Index } from "../index.js"; 3 | 4 | test("add and get", () => { 5 | const index = new Index(); 6 | index.add(1, ["foo", 1]); 7 | index.add(2, ["bar", 1]); 8 | index.add(1, ["baz", 1]); 9 | expect(index.get(1)).toEqual([ 10 | ["foo", 1], 11 | ["baz", 1], 12 | ]); 13 | expect(index.get(2)).toEqual([["bar", 1]]); 14 | }); 15 | 16 | test("join", () => { 17 | const index1 = new Index(); 18 | index1.add(1, ["foo", 1]); 19 | index1.add(2, ["bar", 1]); 20 | index1.add(1, ["baz", 1]); 21 | 22 | const index2 = new Index(); 23 | index2.add(1, ["foo", 1]); 24 | index2.add(2, ["bar", 1]); 25 | index2.add(1, ["baz", 1]); 26 | 27 | const joined = index1.join(index2); 28 | expect(joined.entries).toEqual([ 29 | [["foo", "foo"], 1], 30 | [["foo", "baz"], 1], 31 | [["baz", "foo"], 1], 32 | [["baz", "baz"], 1], 33 | [["bar", "bar"], 1], 34 | ]); 35 | }); 36 | 37 | test("compact", () => { 38 | const index = new Index(); 39 | index.add(1, ["foo", 1]); 40 | index.add(1, ["foo", 1]); 41 | index.add(1, ["foo", 1]); 42 | index.add(2, ["bar", 1]); 43 | index.add(2, ["bar", -1]); 44 | 45 | index.compact(); 46 | expect(index.get(1)).toEqual([["foo", 3]]); 47 | expect(index.get(2)).toEqual([]); 48 | }); 49 | 50 | test("extend", () => { 51 | const index1 = new Index(); 52 | index1.add(1, ["foo", 1]); 53 | index1.add(2, ["bar", 1]); 54 | index1.add(1, ["baz", 1]); 55 | const index2 = new Index(); 56 | index2.add(1, ["foo", 1]); 57 | index2.add(2, ["bar", 1]); 58 | index2.add(1, ["baz", 1]); 59 | index1.extend(index2); 60 | expect(index1.get(1)).toEqual([ 61 | ["foo", 1], 62 | ["baz", 1], 63 | ["foo", 1], 64 | ["baz", 1], 65 | ]); 66 | expect(index1.get(2)).toEqual([ 67 | ["bar", 1], 68 | ["bar", 1], 69 | ]); 70 | }); 71 | -------------------------------------------------------------------------------- /packages/materialite/src/core/__tests__/multiset.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { Multiset } from "../multiset.js"; 3 | 4 | /** 5 | * We need a more efficient difference. 6 | * One that produces small sets when overlap is small. 7 | * 8 | * So no concat negate crazyness. 9 | * 10 | * concat + negate + normalize? 11 | * 12 | * But carrying these concats forward seems bogus. 13 | * 14 | * 15 | */ 16 | 17 | test("map then difference, full change", () => { 18 | const s = new Multiset( 19 | [ 20 | [1, 1], 21 | [2, 1], 22 | [3, 1], 23 | ], 24 | null 25 | ); 26 | const difference = s.map((v) => v + 1).difference(s); 27 | expect(difference.entries).toEqual([ 28 | [2, 1], 29 | [3, 1], 30 | [4, 1], 31 | [1, -1], 32 | [2, -1], 33 | [3, -1], 34 | ]); 35 | const consolidatedDifference = difference.consolidate(); 36 | expect(consolidatedDifference.entries).toEqual([ 37 | [4, 1], 38 | [1, -1], 39 | ]); 40 | }); 41 | 42 | test("map then difference, minor change", () => { 43 | const s = new Multiset( 44 | [ 45 | [1, 1], 46 | [2, 1], 47 | [3, 1], 48 | ], 49 | null 50 | ); 51 | const difference = s.map((v) => (v == 3 ? 4 : v)).difference(s); 52 | const consolidatedDifference = difference.consolidate(); 53 | console.log(consolidatedDifference); 54 | expect(consolidatedDifference.entries).toEqual([ 55 | [4, 1], 56 | [3, -1], 57 | ]); 58 | }); 59 | 60 | test("difference", () => { 61 | // no-op 62 | // all change 63 | // disjoint 64 | }); 65 | 66 | test("test difference and consolidate", () => { 67 | // no-op 68 | // all change 69 | // disjoint 70 | }); 71 | 72 | test("test concat", () => {}); 73 | 74 | test("negate", () => {}); 75 | 76 | test("map", () => {}); 77 | 78 | test("filter", () => {}); 79 | 80 | test("reduce", () => {}); 81 | 82 | test("iterate", () => {}); 83 | 84 | test("equals", () => {}); 85 | -------------------------------------------------------------------------------- /packages/materialite/src/core/consolidation.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 One Law LLC 2 | 3 | import { isTuple } from "@vlcn.io/ds-and-algos/tuple"; 4 | import { objectId } from "@vlcn.io/ds-and-algos/objectTracking"; 5 | 6 | /** 7 | * Compares two values. 8 | * If A and B are tuples (as created by tuple.ts), compares their contents. If they contain tuples themselves, 9 | * compares those recursively. 10 | * 11 | * If A and B are objects, compare them by reference. How do we order references? 12 | * We assign each unique reference a unique integer ID, and order by that. 13 | * 14 | * If A is an object and B is not, A is greater. 15 | * 16 | * If B is an object and A is not, B is greater. 17 | * 18 | * If A and B are primitives, compare them using the standard comparison operators. 19 | * 20 | * @param a 21 | * @param b 22 | * @returns 23 | */ 24 | export function comparator(a: any, b: any): 1 | 0 | -1 { 25 | if (isTuple(a) && isTuple(b)) { 26 | if (a.length > b.length) { 27 | return 1; 28 | } else if (a.length < b.length) { 29 | return -1; 30 | } 31 | for (let i = 0; i < a.length; i++) { 32 | const cmp = comparator(a[i], b[i]); 33 | if (cmp !== 0) { 34 | return cmp; 35 | } 36 | } 37 | return 0; 38 | } 39 | 40 | if (typeof a === "object" && typeof b === "object") { 41 | const aId = objectId(a); 42 | const bId = objectId(b); 43 | if (aId < bId) { 44 | return -1; 45 | } else if (aId > bId) { 46 | return 1; 47 | } 48 | return 0; 49 | } 50 | 51 | if (typeof a === "object") { 52 | return 1; 53 | } 54 | 55 | if (typeof b === "object") { 56 | return -1; 57 | } 58 | 59 | if (a < b) { 60 | return -1; 61 | } else if (a > b) { 62 | return 1; 63 | } 64 | return 0; 65 | } 66 | 67 | export function keyedComaparator(a: [any, any], b: [any, any]): 1 | 0 | -1 { 68 | const cmp = comparator(a[0], b[0]); 69 | return cmp; 70 | } 71 | -------------------------------------------------------------------------------- /packages/materialite/src/core/debug.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 One Law LLC 2 | 3 | import util from "util"; 4 | 5 | export function inspect(e: any) { 6 | console.log(util.inspect(e, false, null, true)); 7 | } 8 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/DifferenceReader.ts: -------------------------------------------------------------------------------- 1 | import { Multiset } from "../multiset.js"; 2 | import { Cause, Version } from "../types.js"; 3 | import { DifferenceStreamWriter } from "./DifferenceWriter.js"; 4 | import { Hoisted } from "./Msg.js"; 5 | import { Queue } from "./Queue.js"; 6 | import { IOperator } from "./ops/Operator.js"; 7 | /** 8 | * A read handle for a dataflow edge that receives data from a writer. 9 | */ 10 | export class DifferenceStreamReader { 11 | protected readonly queue; 12 | readonly #upstream: DifferenceStreamWriter; 13 | #awaitingRecompute = false; 14 | #operator: IOperator; 15 | #lastSeenVersion: Version = -1; 16 | constructor(upstream: DifferenceStreamWriter, queue: Queue) { 17 | this.queue = queue; 18 | this.#upstream = upstream; 19 | } 20 | 21 | destroy() { 22 | this.#upstream.removeReader(this); 23 | this.queue.clear(); 24 | } 25 | 26 | setOperator(operator: IOperator) { 27 | if (this.#operator != null) { 28 | throw new Error("Operator already set!"); 29 | } 30 | this.#operator = operator; 31 | } 32 | 33 | notify(v: Version, cause: Cause) { 34 | if (cause === "full_recompute" || cause === "partial_recompute") { 35 | if (this.#awaitingRecompute === false) { 36 | return; 37 | } 38 | } 39 | this.#awaitingRecompute = false; 40 | this.#lastSeenVersion = v; 41 | this.#operator.run(v); 42 | } 43 | 44 | notifyCommitted(v: Version) { 45 | // If we did not process this version in this oeprator 46 | // then we should not pass notifications down this path. 47 | if (v !== this.#lastSeenVersion) { 48 | return; 49 | } 50 | this.#operator.notifyCommitted(v); 51 | } 52 | 53 | drain(version: Version) { 54 | const ret: Multiset[] = []; 55 | while (true) { 56 | const node = this.queue.peek(); 57 | if (node == null) { 58 | break; 59 | } 60 | if (node.data[0] > version) { 61 | break; 62 | } 63 | ret.push(node.data[1]); 64 | this.queue.dequeue(); 65 | } 66 | return ret; 67 | } 68 | 69 | pull(msg: Hoisted) { 70 | this.#awaitingRecompute = true; 71 | this.queue.prepareForRecompute(); 72 | this.#upstream.pull(msg); 73 | } 74 | 75 | isEmpty() { 76 | return this.queue.isEmpty(); 77 | } 78 | } 79 | 80 | export class DifferenceStreamReaderFromRoot< 81 | T 82 | > extends DifferenceStreamReader { 83 | drain(version: Version) { 84 | if (this.queue.isEmpty()) { 85 | return [new Multiset([], null)]; 86 | } else { 87 | return super.drain(version); 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/DifferenceStream.ts: -------------------------------------------------------------------------------- 1 | import { Materialite } from "../../materialite.js"; 2 | import { AbstractDifferenceStream } from "./AbstractDifferenceStream.js"; 3 | import { DifferenceStreamWriter } from "./DifferenceWriter.js"; 4 | import { Hoisted } from "./Msg.js"; 5 | 6 | export class DifferenceStream extends AbstractDifferenceStream { 7 | constructor(materialite: Materialite) { 8 | super(materialite, new DifferenceStreamWriter()); 9 | } 10 | 11 | pull(msg: Hoisted) { 12 | this.writer.pull(msg); 13 | } 14 | 15 | protected newStream(): AbstractDifferenceStream { 16 | return new DifferenceStream(this.materialite); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/HoistableDifferenceStream.ts: -------------------------------------------------------------------------------- 1 | // extends AbstractDifferenceStream 2 | // hoistable ops return hoistable streams 3 | 4 | import { Comparator } from "@vlcn.io/ds-and-algos/types"; 5 | import { AbstractDifferenceStream } from "./AbstractDifferenceStream.js"; 6 | import { DifferenceStreamWriter } from "./DifferenceWriter.js"; 7 | import { HoistableAfterOperator } from "./ops/HoistableAfterOperator.js"; 8 | import { DifferenceStream } from "./DifferenceStream.js"; 9 | 10 | export class HoistableDifferenceStream extends AbstractDifferenceStream { 11 | protected newStream(): AbstractDifferenceStream { 12 | return new DifferenceStream(this.materialite); 13 | } 14 | 15 | after(v: T, comparator: Comparator): HoistableDifferenceStream { 16 | const ret = new HoistableDifferenceStream( 17 | this.materialite, 18 | new DifferenceStreamWriter() 19 | ); 20 | new HoistableAfterOperator( 21 | this.writer.newReader(), 22 | ret.writer, 23 | v, 24 | comparator 25 | ); 26 | return ret; 27 | } 28 | 29 | // take is hoistable but returns non-hoistable 30 | // filter is hoistable if given info 31 | // e.g., filter('key', 'op', value); 32 | } 33 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/IDifferenceStream.ts: -------------------------------------------------------------------------------- 1 | import { Entry } from "../multiset.js"; 2 | import { JoinResultVariadic } from "@vlcn.io/ds-and-algos/tuple"; 3 | import { Comparator } from "@vlcn.io/ds-and-algos/types"; 4 | import { ValueView } from "../../views/PrimitiveView.js"; 5 | import { PersistentTreeView } from "../../views/PersistentTreeView.js"; 6 | import { View } from "../../views/View.js"; 7 | import { CopyOnWriteArrayView } from "../../views/CopyOnWriteArrayView.js"; 8 | import { ArrayView } from "../../views/ArrayView.js"; 9 | 10 | export type MaterializeOptions = { 11 | wantInitialData?: boolean; 12 | limit?: number; 13 | name?: string; 14 | }; 15 | export type EffectOptions = MaterializeOptions; 16 | 17 | export interface IDifferenceStream { 18 | after(v: T, comparator: Comparator): IDifferenceStream; 19 | take(n: number, comparator: Comparator): IDifferenceStream; 20 | map(f: (value: T) => O): IDifferenceStream; 21 | filter(f: (x: T) => x is S): IDifferenceStream; 22 | filter(f: (x: T) => boolean): IDifferenceStream; 23 | negate(): IDifferenceStream; 24 | concat(other: IDifferenceStream): IDifferenceStream; 25 | join( 26 | other: IDifferenceStream, 27 | getKeyThis: (i: T) => K, 28 | getKeyOther: (i: R) => K 29 | ): IDifferenceStream< 30 | T extends JoinResultVariadic<[infer T1, ...infer T2]> 31 | ? JoinResultVariadic<[T1, ...T2, R]> 32 | : JoinResultVariadic<[T, R]> 33 | >; 34 | // TODO: implement a reduce that doesn't expose `Entry` 35 | reduce( 36 | fn: (i: Entry[]) => Entry[], 37 | getKey: (i: T) => K 38 | ): IDifferenceStream; 39 | count(getKey: (i: T) => K): IDifferenceStream; 40 | size(): IDifferenceStream; 41 | effect(f: (i: T) => void, options: EffectOptions): IDifferenceStream; 42 | 43 | materializeInto, V, VC>( 44 | ctor: (stream: this) => T, 45 | options: MaterializeOptions 46 | ): T; 47 | materialize( 48 | c: Comparator, 49 | options: MaterializeOptions 50 | ): PersistentTreeView; 51 | materializeValue( 52 | this: IDifferenceStream, 53 | initial: T, 54 | options: MaterializeOptions 55 | ): ValueView; 56 | materializeCopyOnWriteArray( 57 | c: Comparator, 58 | options: MaterializeOptions 59 | ): CopyOnWriteArrayView; 60 | materializeArray(c: Comparator, options: MaterializeOptions): ArrayView; 61 | } 62 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/Msg.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * To facilitate Materialite's ability to use actual databases as sources, 3 | * these messages are passed down to the source from operators. 4 | * 5 | * This is a similar idea to Aphrodite where each 6 | * operator also had information to pass down to 7 | * "source expressions". The source expression could use that information 8 | * to hoist operations to the DB. 9 | * 10 | * See: https://tantaman.com/2022-05-26-query-planning.html 11 | */ 12 | export type OperatorExpression = AfterExpression; 13 | 14 | export type AfterExpression = { 15 | readonly _tag: "after"; 16 | readonly cursor: T; 17 | readonly comparator: (a: T, b: T) => number; 18 | }; 19 | 20 | export type TakeMsg = { 21 | readonly count: number; 22 | }; 23 | 24 | export type Hoisted = { 25 | // readonly version: number; 26 | readonly expressions: OperatorExpression[]; 27 | }; 28 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/Queue.ts: -------------------------------------------------------------------------------- 1 | // Queue abstraction that sits between readers and writers. 2 | // This queue understands what version it has sent and will not send old data 3 | // unless explicitly requested to do so. 4 | // E.g., a re-pull would re-set operators and allow old data to be sent. 5 | // re-pulls need to happen if views are attached late to a data stream. 6 | // Those view are missing all the history and so need to issue a re-pull for past data. 7 | 8 | import { Multiset, isRecmopute } from "../multiset.js"; 9 | import { Version } from "../types.js"; 10 | 11 | type Node = { 12 | data: [Version, Multiset]; 13 | next: Node | null; 14 | }; 15 | 16 | export class Queue { 17 | #lastSeenVersion = -1; 18 | #awaitingRecompute = false; 19 | #head: Node | null = null; 20 | #tail: Node | null = null; 21 | 22 | enqueue(data: [number, Multiset]) { 23 | if (data[0] <= this.#lastSeenVersion) { 24 | // throw an error? 25 | console.warn("enqueueing old data"); 26 | return; 27 | } 28 | if (isRecmopute(data[1])) { 29 | if (this.#awaitingRecompute) { 30 | this.#awaitingRecompute = false; 31 | } else { 32 | // this channel didn't pull for old data 33 | return; 34 | } 35 | } 36 | this.#lastSeenVersion = data[0]; 37 | const node = { data, next: null }; 38 | if (this.#head == null) { 39 | this.#head = node; 40 | } else { 41 | this.#tail!.next = node; 42 | } 43 | this.#tail = node; 44 | } 45 | 46 | prepareForRecompute() { 47 | this.#awaitingRecompute = true; 48 | } 49 | 50 | peek() { 51 | return this.#head; 52 | } 53 | 54 | dequeue() { 55 | if (this.#head == null) { 56 | return null; 57 | } 58 | const ret = this.#head.data; 59 | this.#head = this.#head.next; 60 | if (this.#head == null) { 61 | this.#tail = null; 62 | } 63 | return ret; 64 | } 65 | 66 | isEmpty() { 67 | return this.#head == null; 68 | } 69 | 70 | clear() { 71 | this.#head = null; 72 | this.#tail = null; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/RootDifferenceStream.ts: -------------------------------------------------------------------------------- 1 | import { Materialite } from "../../materialite.js"; 2 | import { Source } from "../../sources/Source.js"; 3 | import { RootDifferenceStreamWriter } from "./DifferenceWriter.js"; 4 | import { HoistableDifferenceStream } from "./HoistableDifferenceStream.js"; 5 | 6 | export class RootDifferenceStream extends HoistableDifferenceStream { 7 | constructor(materialite: Materialite, source: Source) { 8 | super(materialite, new RootDifferenceStreamWriter(source)); 9 | } 10 | } 11 | 12 | /** 13 | * pull... 14 | * 15 | * changing writers but do we need to change readers too? 16 | * 17 | * source - w - 18 | */ 19 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/ops/AfterOperator.ts: -------------------------------------------------------------------------------- 1 | import { Comparator } from "@vlcn.io/ds-and-algos/types"; 2 | import { Multiset } from "../../multiset.js"; 3 | import { DifferenceStreamReader } from "../DifferenceReader.js"; 4 | import { DifferenceStreamWriter } from "../DifferenceWriter.js"; 5 | import { LinearUnaryOperator } from "./LinearUnaryOperator.js"; 6 | 7 | // See HoistedAfterOperator for an after that pushes 8 | // itself down to the source. 9 | export class AfterOperator extends LinearUnaryOperator { 10 | constructor( 11 | input: DifferenceStreamReader, 12 | output: DifferenceStreamWriter, 13 | v: T, 14 | comparator: Comparator 15 | ) { 16 | const inner = (input: Multiset) => { 17 | return input.filter((x) => comparator(x, v) >= 0); 18 | }; 19 | super(input, output, inner); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/ops/BinaryOperator.ts: -------------------------------------------------------------------------------- 1 | import { Multiset } from "../../multiset.js"; 2 | import { Version } from "../../types.js"; 3 | import { DifferenceStreamReader } from "../DifferenceReader.js"; 4 | import { DifferenceStreamWriter } from "../DifferenceWriter.js"; 5 | import { Operator } from "./Operator.js"; 6 | 7 | export class BinaryOperator extends Operator { 8 | constructor( 9 | input1: DifferenceStreamReader, 10 | input2: DifferenceStreamReader, 11 | output: DifferenceStreamWriter, 12 | fn: (v: Version) => void 13 | ) { 14 | super([input1, input2], output, fn); 15 | } 16 | 17 | inputAMessages(version: Version) { 18 | return (this.inputs[0]?.drain(version) ?? []) as Multiset[]; 19 | } 20 | 21 | inputBMessages(version: Version) { 22 | return (this.inputs[1]?.drain(version) ?? []) as Multiset[]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/ops/ConcatOperator.ts: -------------------------------------------------------------------------------- 1 | import { Multiset } from "../../multiset.js"; 2 | import { Version } from "../../types.js"; 3 | import { DifferenceStreamReader } from "../DifferenceReader.js"; 4 | import { DifferenceStreamWriter } from "../DifferenceWriter.js"; 5 | import { BinaryOperator } from "./BinaryOperator.js"; 6 | 7 | export class ConcatOperator extends BinaryOperator { 8 | #inputAPending: Multiset[] = []; 9 | #inputBPending: Multiset[] = []; 10 | 11 | constructor( 12 | input1: DifferenceStreamReader, 13 | input2: DifferenceStreamReader, 14 | output: DifferenceStreamWriter 15 | ) { 16 | const inner = (v: Version) => { 17 | for (const collection of this.inputAMessages(v)) { 18 | this.#inputAPending.push(collection); 19 | } 20 | for (const collection of this.inputBMessages(v)) { 21 | this.#inputBPending.push(collection); 22 | } 23 | 24 | while (this.#inputAPending.length > 0 && this.#inputBPending.length > 0) { 25 | const a = this.#inputAPending.shift(); 26 | const b = this.#inputBPending.shift(); 27 | this.output.sendData(v, a!.concat(b!)); 28 | } 29 | }; 30 | super(input1, input2, output, inner); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/ops/CountOperator.ts: -------------------------------------------------------------------------------- 1 | import { Entry, Multiset } from "../../multiset.js"; 2 | import { DifferenceStreamReader } from "../DifferenceReader.js"; 3 | import { DifferenceStreamWriter } from "../DifferenceWriter.js"; 4 | import { LinearUnaryOperator } from "./LinearUnaryOperator.js"; 5 | import { ReduceOperator } from "./ReduceOperator.js"; 6 | 7 | // TODO: count is technically linear. We can do this as a linear operator so as not to require 8 | // retaining values. Maybe add `LinearReduceOperator`? 9 | export class CountOperator extends ReduceOperator { 10 | constructor( 11 | input: DifferenceStreamReader, 12 | output: DifferenceStreamWriter, 13 | getKey: (value: V) => K 14 | ) { 15 | const inner = (vals: Entry[]): [Entry] => { 16 | let count = 0; 17 | for (const [_, mult] of vals) { 18 | count += mult; 19 | } 20 | return [[count, 1]]; 21 | }; 22 | super(input, output, getKey, inner); 23 | } 24 | } 25 | 26 | export class LinearCountOperator extends LinearUnaryOperator { 27 | #state: number = 0; 28 | constructor( 29 | input: DifferenceStreamReader, 30 | output: DifferenceStreamWriter 31 | ) { 32 | const inner = (collection: Multiset) => { 33 | // TODO: handle partial recompute....... should we even? 34 | if (collection.eventMetadata?.cause === "full_recompute") { 35 | this.#state = 0; 36 | } 37 | 38 | for (const e of collection.entries) { 39 | this.#state += e[1]; 40 | } 41 | return new Multiset([[this.#state, 1]], collection.eventMetadata); 42 | }; 43 | super(input, output, inner); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/ops/DistinctOperator.ts: -------------------------------------------------------------------------------- 1 | // distincts by key. 2 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/ops/EffectOperator.ts: -------------------------------------------------------------------------------- 1 | import { Multiset } from "../../multiset.js"; 2 | import { Version } from "../../types.js"; 3 | import { DifferenceStreamReader } from "../DifferenceReader.js"; 4 | import { DifferenceStreamWriter } from "../DifferenceWriter.js"; 5 | import { UnaryOperator } from "./UnaryOperator.js"; 6 | 7 | /** 8 | * Runs an effect _after_ a transaction has been committed. 9 | * Does not observe deleted values. 10 | */ 11 | export class EffectOperator extends UnaryOperator { 12 | readonly #f: (input: T) => void; 13 | #collected: Multiset[] = []; 14 | 15 | constructor( 16 | input: DifferenceStreamReader, 17 | output: DifferenceStreamWriter, 18 | f: (input: T) => void 19 | ) { 20 | const inner = (version: Version) => { 21 | this.#collected = []; 22 | for (const collection of this.inputMessages(version)) { 23 | this.#collected.push(collection); 24 | this.output.sendData(version, collection); 25 | } 26 | }; 27 | super(input, output, inner); 28 | this.#f = f; 29 | } 30 | 31 | notifyCommitted(v: number): void { 32 | for (const collection of this.#collected) { 33 | for (const [val, mult] of collection.entries) { 34 | if (mult > 0) { 35 | this.#f(val); 36 | } 37 | } 38 | } 39 | this.output.notifyCommitted(v); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/ops/FilterOperator.ts: -------------------------------------------------------------------------------- 1 | import { Multiset } from "../../multiset.js"; 2 | import { DifferenceStreamReader } from "../DifferenceReader.js"; 3 | import { DifferenceStreamWriter } from "../DifferenceWriter.js"; 4 | import { LinearUnaryOperator } from "./LinearUnaryOperator.js"; 5 | 6 | export class FilterOperator extends LinearUnaryOperator { 7 | constructor( 8 | input: DifferenceStreamReader, 9 | output: DifferenceStreamWriter, 10 | f: (input: I) => boolean 11 | ) { 12 | const inner = (collection: Multiset) => { 13 | return collection.filter(f); 14 | }; 15 | super(input, output, inner); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/ops/HoistableAfterOperator.ts: -------------------------------------------------------------------------------- 1 | import { Comparator } from "@vlcn.io/ds-and-algos/types"; 2 | import { DifferenceStreamReader } from "../DifferenceReader.js"; 3 | import { DifferenceStreamWriter } from "../DifferenceWriter.js"; 4 | import { AfterExpression, Hoisted } from "../Msg.js"; 5 | import { AfterOperator } from "./AfterOperator.js"; 6 | 7 | // Could be implemented as `filter` but `after` is able to push the operation down to the `source`. 8 | export class HoistableAfterOperator extends AfterOperator { 9 | readonly #comparator; 10 | readonly #cursor; 11 | 12 | constructor( 13 | input: DifferenceStreamReader, 14 | output: DifferenceStreamWriter, 15 | v: T, 16 | comparator: Comparator 17 | ) { 18 | super(input, output, v, comparator); 19 | this.#comparator = comparator; 20 | this.#cursor = v; 21 | } 22 | 23 | pull(msg: Hoisted) { 24 | const extra: AfterExpression = { 25 | _tag: "after", 26 | cursor: this.#cursor, 27 | comparator: this.#comparator, 28 | }; 29 | msg.expressions.push(extra); 30 | super.pull(msg); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/ops/JoinOperator.ts: -------------------------------------------------------------------------------- 1 | import { JoinResultVariadic } from "@vlcn.io/ds-and-algos/tuple"; 2 | import { BinaryOperator } from "./BinaryOperator.js"; 3 | import { Index } from "../../index.js"; 4 | import { DifferenceStreamReader } from "../DifferenceReader.js"; 5 | import { DifferenceStreamWriter } from "../DifferenceWriter.js"; 6 | import { Version } from "../../types.js"; 7 | import { Multiset } from "../../multiset.js"; 8 | 9 | /** 10 | * TODO: improve this via randomized binary search trees? 11 | */ 12 | export class JoinOperator extends BinaryOperator< 13 | V1, 14 | V2, 15 | JoinResultVariadic<[V1, V2]> 16 | > { 17 | readonly #indexA = new Index(); 18 | readonly #indexB = new Index(); 19 | readonly #inputAPending: Index[] = []; 20 | readonly #inputBPending: Index[] = []; 21 | 22 | constructor( 23 | inputA: DifferenceStreamReader, 24 | inputB: DifferenceStreamReader, 25 | output: DifferenceStreamWriter>, 26 | getKeyA: (value: V1) => K, 27 | getKeyB: (value: V2) => K 28 | ) { 29 | // TODO: how to deal with full re-compute messages 30 | // and to forward down a full-recompute changeset 31 | const inner = (version: Version) => { 32 | for (const collection of this.inputAMessages(version)) { 33 | const deltaA = new Index(); 34 | for (const [value, mult] of collection.entries) { 35 | deltaA.add(getKeyA(value), [value, mult]); 36 | } 37 | this.#inputAPending.push(deltaA); 38 | } 39 | 40 | for (const collection of this.inputBMessages(version)) { 41 | const deltaB = new Index(); 42 | for (const [value, mult] of collection.entries) { 43 | deltaB.add(getKeyB(value), [value, mult]); 44 | } 45 | this.#inputBPending.push(deltaB); 46 | } 47 | 48 | // TODO: join should still be able to operate even if one of the inputs is empty... 49 | // right? 50 | while (this.#inputAPending.length > 0 && this.#inputBPending.length > 0) { 51 | const result = new Multiset>([], null); 52 | const deltaA = this.#inputAPending.shift()!; 53 | const deltaB = this.#inputBPending.shift()!; 54 | 55 | result._extend(deltaA.join(this.#indexB)); 56 | this.#indexA.extend(deltaA); 57 | result._extend(this.#indexA.join(deltaB)); 58 | this.#indexB.extend(deltaB); 59 | 60 | this.output.sendData(version, result.consolidate() as any); 61 | this.#indexA.compact(); 62 | this.#indexB.compact(); 63 | } 64 | }; 65 | super(inputA, inputB, output, inner); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/ops/LinearUnaryOperator.ts: -------------------------------------------------------------------------------- 1 | import { Multiset } from "../../multiset.js"; 2 | import { Version } from "../../types.js"; 3 | import { DifferenceStreamReader } from "../DifferenceReader.js"; 4 | import { DifferenceStreamWriter } from "../DifferenceWriter.js"; 5 | import { UnaryOperator } from "./UnaryOperator.js"; 6 | 7 | export class LinearUnaryOperator extends UnaryOperator { 8 | constructor( 9 | input: DifferenceStreamReader, 10 | output: DifferenceStreamWriter, 11 | f: (input: Multiset) => Multiset 12 | ) { 13 | const inner = (v: Version) => { 14 | for (const collection of this.inputMessages(v)) { 15 | this.output.sendData(v, f(collection)); 16 | } 17 | }; 18 | super(input, output, inner); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/ops/MapOperator.ts: -------------------------------------------------------------------------------- 1 | import { Multiset } from "../../multiset.js"; 2 | import { DifferenceStreamReader } from "../DifferenceReader.js"; 3 | import { DifferenceStreamWriter } from "../DifferenceWriter.js"; 4 | import { LinearUnaryOperator } from "./LinearUnaryOperator.js"; 5 | 6 | export class MapOperator extends LinearUnaryOperator { 7 | constructor( 8 | input: DifferenceStreamReader, 9 | output: DifferenceStreamWriter, 10 | f: (input: I) => O 11 | ) { 12 | const inner = (collection: Multiset) => { 13 | return collection.map(f); 14 | }; 15 | super(input, output, inner); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/ops/MaxOperator.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlcn-io/materialite/e08c1fa176883a51296d7c2c3a86c539c0e72eab/packages/materialite/src/core/graph/ops/MaxOperator.ts -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/ops/MinOperator.ts: -------------------------------------------------------------------------------- 1 | // import { Multiset } from "../../multiset.js"; 2 | // import { DifferenceStreamReader } from "../DifferenceReader.js"; 3 | // import { DifferenceStreamWriter } from "../DifferenceWriter.js"; 4 | // import { LinearUnaryOperator } from "./LinearUnaryOperator.js"; 5 | 6 | // export class MinOperator extends LinearUnaryOperator { 7 | // constructor( 8 | // input: DifferenceStreamReader, 9 | // output: DifferenceStreamWriter, 10 | // minBy: (a: I) => number, 11 | // ) { 12 | // // TODO: need to maintain a heap so we can remove minimums 13 | // let min = Number.POSITIVE_INFINITY; 14 | // const inner = (collection: Multiset) => { 15 | // for (const item of collection.entries) { 16 | // const num = minBy(item[0]); 17 | // } 18 | // }; 19 | // super(input, output, inner); 20 | // } 21 | // } 22 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/ops/NegateOperator.ts: -------------------------------------------------------------------------------- 1 | import { Multiset } from "../../multiset.js"; 2 | import { DifferenceStreamReader } from "../DifferenceReader.js"; 3 | import { DifferenceStreamWriter } from "../DifferenceWriter.js"; 4 | import { LinearUnaryOperator } from "./LinearUnaryOperator.js"; 5 | 6 | export class NegateOperator extends LinearUnaryOperator { 7 | constructor( 8 | input: DifferenceStreamReader, 9 | output: DifferenceStreamWriter 10 | ) { 11 | const inner = (collection: Multiset) => { 12 | return collection.negate(); 13 | }; 14 | super(input, output, inner); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/ops/Operator.ts: -------------------------------------------------------------------------------- 1 | import { Version } from "../../types.js"; 2 | import { DifferenceStreamReader } from "../DifferenceReader.js"; 3 | import { DifferenceStreamWriter } from "../DifferenceWriter.js"; 4 | import { Hoisted } from "../Msg.js"; 5 | 6 | export interface IOperator { 7 | run(version: Version): void; 8 | pull(msg: Hoisted): void; 9 | destroy(): void; 10 | notifyCommitted(v: Version): void; 11 | } 12 | /** 13 | * A dataflow operator (node) that has many incoming edges (read handles) and one outgoing edge (write handle). 14 | */ 15 | export class Operator implements IOperator { 16 | readonly #fn; 17 | protected _pendingWork: boolean = false; 18 | 19 | constructor( 20 | protected readonly inputs: DifferenceStreamReader[], 21 | protected readonly output: DifferenceStreamWriter, 22 | fn: (v: Version) => void 23 | ) { 24 | this.#fn = fn; 25 | for (const input of inputs) { 26 | input.setOperator(this); 27 | } 28 | this.output.setOperator(this); 29 | } 30 | 31 | run(v: Version) { 32 | this.#fn(v); 33 | } 34 | 35 | notifyCommitted(v: Version) { 36 | this.output.notifyCommitted(v); 37 | } 38 | 39 | pendingWork() { 40 | if (this._pendingWork) { 41 | return true; 42 | } 43 | for (const input of this.inputs) { 44 | if (!input.isEmpty()) { 45 | return true; 46 | } 47 | } 48 | return false; 49 | } 50 | 51 | /** 52 | * If an operator is pulled, it sends the pull 53 | * up the stream to its inputs. 54 | * @param msg 55 | * @returns 56 | */ 57 | pull(msg: Hoisted) { 58 | for (const input of this.inputs) { 59 | input.pull(msg); 60 | } 61 | } 62 | 63 | destroy(): void { 64 | for (const input of this.inputs) { 65 | input.destroy(); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/ops/ReduceOperator.ts: -------------------------------------------------------------------------------- 1 | import { Index } from "../../index.js"; 2 | import { Entry, Multiset } from "../../multiset.js"; 3 | import { DifferenceStreamReader } from "../DifferenceReader.js"; 4 | import { DifferenceStreamWriter } from "../DifferenceWriter.js"; 5 | import { UnaryOperator } from "./UnaryOperator.js"; 6 | import { Version } from "../../types.js"; 7 | import { TuplableMap } from "@vlcn.io/ds-and-algos/TuplableMap"; 8 | 9 | export class ReduceOperator extends UnaryOperator { 10 | readonly #index = new Index(); 11 | readonly #indexOut = new Index(); 12 | 13 | constructor( 14 | input: DifferenceStreamReader, 15 | output: DifferenceStreamWriter, 16 | getKey: (value: V) => K, 17 | f: (input: Entry[]) => Entry[] 18 | ) { 19 | // TODO: deal with full recompute messages 20 | const subtractValues = (first: Entry[], second: Entry[]) => { 21 | const result = new TuplableMap(); 22 | for (const [v1, m1] of first) { 23 | const sum = (result.get(v1) || 0) + m1; 24 | if (sum === 0) { 25 | result.delete(v1); 26 | } else { 27 | result.set(v1, sum); 28 | } 29 | } 30 | for (const [v2, m2] of second) { 31 | const sum = (result.get(v2) || 0) - m2; 32 | if (sum === 0) { 33 | result.delete(v2); 34 | } else { 35 | result.set(v2, sum); 36 | } 37 | } 38 | 39 | return result.entries(); 40 | }; 41 | const inner = (version: Version) => { 42 | for (const collection of this.inputMessages(version)) { 43 | const keysTodo = new Set(); 44 | const result: [O, number][] = []; 45 | for (const [value, mult] of collection.entries) { 46 | const key = getKey(value); 47 | this.#index.add(key, [value, mult]); 48 | keysTodo.add(key); 49 | } 50 | for (const key of keysTodo) { 51 | const curr = this.#index.get(key); 52 | const currOut = this.#indexOut.get(key); 53 | const out = f(curr); 54 | const delta = subtractValues(out, currOut); 55 | for (const [value, mult] of delta) { 56 | result.push([value, mult]); 57 | this.#indexOut.add(key, [value, mult]); 58 | } 59 | } 60 | this.output.sendData(version, new Multiset(result, null)); 61 | const keys = [...keysTodo.values()]; 62 | this.#index.compact(keys); 63 | this.#indexOut.compact(keys); 64 | } 65 | }; 66 | super(input, output, inner); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/ops/SplitOperator.ts: -------------------------------------------------------------------------------- 1 | // import { Index } from "../../index.js"; 2 | // import { Entry, Multiset } from "../../multiset.js"; 3 | // import { DifferenceStreamReader } from "../DifferenceReader.js"; 4 | // import { DifferenceStreamWriter } from "../DifferenceWriter.js"; 5 | // import { UnaryOperator } from "./UnaryOperator.js"; 6 | // import { Version } from "../../types.js"; 7 | // import { TuplableMap } from "@vlcn.io/ds-and-algos/TuplableMap"; 8 | // import { IOperator } from "./Operator.js"; 9 | 10 | // export class SplitOperator implements IOperator { 11 | // constructor( 12 | // input: DifferenceStreamReader, 13 | // output: DifferenceStreamWriter, 14 | // getKey: (value: V) => K 15 | // ) { 16 | // const inner = (version: Version) => { 17 | // // 18 | // }; 19 | // super(input, output, inner); 20 | // } 21 | // } 22 | -------------------------------------------------------------------------------- /packages/materialite/src/core/graph/ops/UnaryOperator.ts: -------------------------------------------------------------------------------- 1 | import { Multiset } from "../../multiset.js"; 2 | import { Version } from "../../types.js"; 3 | import { DifferenceStreamReader } from "../DifferenceReader.js"; 4 | import { DifferenceStreamWriter } from "../DifferenceWriter.js"; 5 | import { Operator } from "./Operator.js"; 6 | 7 | export class UnaryOperator extends Operator { 8 | constructor( 9 | input: DifferenceStreamReader, 10 | output: DifferenceStreamWriter, 11 | fn: (e: Version) => void 12 | ) { 13 | super([input], output, fn); 14 | } 15 | 16 | inputMessages(version: Version) { 17 | return (this.inputs[0]?.drain(version) ?? []) as Multiset[]; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/materialite/src/core/types.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 One Law LLC 2 | 3 | import { Comparator } from "@vlcn.io/ds-and-algos/types"; 4 | import { Materialite } from "../materialite.js"; 5 | 6 | export type Version = number; 7 | export interface ISourceInternal { 8 | // Add values to queues 9 | onCommitPhase1(version: Version): void; 10 | // Drain queues and run the reactive graph 11 | // TODO: we currently can't rollback a transaction in phase 2 12 | onCommitPhase2(version: Version): void; 13 | // Now that the graph has computed itself fully, notify effects / listeners 14 | onCommitted(version: Version): void; 15 | onRollback(): void; 16 | } 17 | export type MaterialiteForSourceInternal = { 18 | readonly materialite: Materialite; 19 | addDirtySource(source: ISourceInternal): void; 20 | }; 21 | export type DestroyOptions = { autoCleanup?: boolean }; 22 | 23 | export type Cause = "full_recompute" | "partial_recompute" | "difference"; 24 | export type EventMetadata = 25 | | { 26 | cause: "full_recompute"; 27 | // comparator from the source 28 | // so operators know if they can bail early or not 29 | comparator?: Comparator; 30 | } 31 | | { 32 | cause: "partial_recompute"; 33 | // comparator from the source 34 | // so operators know if they can bail early or not 35 | comparator?: Comparator; 36 | } 37 | | { 38 | cause: "difference"; 39 | }; 40 | -------------------------------------------------------------------------------- /packages/materialite/src/index.ts: -------------------------------------------------------------------------------- 1 | // Top level APIs 2 | export { Materialite } from "./materialite.js"; 3 | export { AbstractDifferenceStream as DifferenceStream } from "./core/graph/AbstractDifferenceStream.js"; 4 | export { ISignal } from "./signal/ISignal.js"; 5 | export { IDifferenceStream } from "./core/graph/IDifferenceStream.js"; 6 | 7 | // Sources 8 | export { SetSource } from "./sources/StatelessSetSource.js"; 9 | export { ImmutableSetSource as PersistentSetSource } from "./sources/ImmutableSetSource.js"; 10 | export { MutableMapSource } from "./sources/MutableMapSource.js"; 11 | export { MutableSetSource } from "./sources/MutableSetSource.js"; 12 | export type { IStatefulSource, ISource } from "./sources/Source.js"; 13 | 14 | // Views 15 | export { ValueView as PrimitiveView } from "./views/PrimitiveView.js"; 16 | export { PersistentTreeView } from "./views/PersistentTreeView.js"; 17 | 18 | // Re-Exports 19 | export { PersistentTreap } from "@vlcn.io/ds-and-algos/PersistentTreap"; 20 | -------------------------------------------------------------------------------- /packages/materialite/src/signal/Atom.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ISourceInternal, 3 | MaterialiteForSourceInternal, 4 | Version, 5 | } from "../core/types.js"; 6 | import { IDerivation, ISignal } from "./ISignal.js"; 7 | 8 | export class Atom implements ISignal { 9 | #pending: T | undefined; 10 | #value: T; 11 | 12 | readonly #internal: ISourceInternal; 13 | readonly #materialite; 14 | readonly #listeners = new Set<(value: T, version: number) => void>(); 15 | readonly #derivations = new Set>(); 16 | 17 | constructor(materialite: MaterialiteForSourceInternal, v: T) { 18 | this.#value = v; 19 | this.#materialite = materialite; 20 | const self = this; 21 | // These are only called if the atom was changed in the transaction 22 | this.#internal = { 23 | onCommitPhase1(_: Version) { 24 | if (self.#pending !== undefined) { 25 | self.#value = self.#pending; 26 | self.#pending = undefined; 27 | } 28 | }, 29 | onCommitPhase2(v: Version) { 30 | for (const d of self.#derivations) { 31 | d.onSignalChanged(self.#value, v); 32 | } 33 | }, 34 | onCommitted(v: Version) { 35 | for (const l of self.#listeners) { 36 | l(self.#value, v); 37 | } 38 | for (const d of self.#derivations) { 39 | d.onCommitted(self.#value, v); 40 | } 41 | }, 42 | onRollback() { 43 | self.#pending = undefined; 44 | }, 45 | }; 46 | } 47 | 48 | pipe(f: (v: T) => R): ISignal { 49 | return this.#materialite.materialite.compute(f, this); 50 | } 51 | 52 | _derive(derivation: IDerivation): () => void { 53 | this.#derivations.add(derivation); 54 | return () => { 55 | this.#derivations.delete(derivation); 56 | }; 57 | } 58 | 59 | on(fn: (value: T, version: number) => void): () => void { 60 | this.#listeners.add(fn); 61 | return () => { 62 | this.#listeners.delete(fn); 63 | }; 64 | } 65 | 66 | off(fn: (value: T, version: number) => void): void { 67 | this.#listeners.delete(fn); 68 | } 69 | 70 | destroy() { 71 | this.#listeners.clear(); 72 | this.#derivations.clear(); 73 | } 74 | 75 | get value() { 76 | return this.#value; 77 | } 78 | 79 | set value(v: T) { 80 | if (v === this.#value) { 81 | return; 82 | } 83 | this.#pending = v; 84 | this.#materialite.addDirtySource(this.#internal); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /packages/materialite/src/signal/ISignal.ts: -------------------------------------------------------------------------------- 1 | export interface ISignal { 2 | // TODO: move these to an internal interface 3 | _derive(derivation: IDerivation): () => void; 4 | 5 | on(fn: (value: T, version: number) => void): () => void; 6 | /** 7 | * If there are 0 listeners left after removing the given listener, 8 | * the signal is destroyed. 9 | * 10 | * To opt out of this behavior, pass `autoCleanup: false` 11 | * @param listener 12 | */ 13 | off( 14 | fn: (value: T, version: number) => void, 15 | options?: { autoCleanup?: boolean } 16 | ): void; 17 | get value(): T; 18 | 19 | pipe(f: (v: T) => R): ISignal; 20 | } 21 | 22 | export interface IDerivation { 23 | onSignalChanged(value: T, version: number): void; 24 | onCommitted(value: T, version: number): void; 25 | } 26 | -------------------------------------------------------------------------------- /packages/materialite/src/sources/ImmutableSetSource.ts: -------------------------------------------------------------------------------- 1 | import { PersistentTreap } from "@vlcn.io/ds-and-algos/PersistentTreap"; 2 | import { MaterialiteForSourceInternal } from "../core/types.js"; 3 | import { Comparator } from "@vlcn.io/ds-and-algos/types"; 4 | import { StatefulSetSource } from "./StatefulSetSource.js"; 5 | 6 | /** 7 | * A set source that retains its contents in an immutable data structure. 8 | */ 9 | export class ImmutableSetSource extends StatefulSetSource { 10 | constructor( 11 | materialite: MaterialiteForSourceInternal, 12 | comparator: Comparator 13 | ) { 14 | super( 15 | materialite, 16 | comparator, 17 | (comparator) => new PersistentTreap(comparator) 18 | ); 19 | } 20 | 21 | withNewOrdering(comp: Comparator): this { 22 | const ret = new ImmutableSetSource(this.materialite, comp); 23 | this.materialite.materialite.tx(() => { 24 | for (const v of this.value) { 25 | ret.add(v); 26 | } 27 | }); 28 | return ret as any; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/materialite/src/sources/MutableSetSource.ts: -------------------------------------------------------------------------------- 1 | import { Treap } from "@vlcn.io/ds-and-algos/Treap"; 2 | import { StatefulSetSource } from "./StatefulSetSource.js"; 3 | import { MaterialiteForSourceInternal } from "../core/types.js"; 4 | import { Comparator } from "@vlcn.io/ds-and-algos/types"; 5 | 6 | export class MutableSetSource extends StatefulSetSource { 7 | constructor( 8 | materialite: MaterialiteForSourceInternal, 9 | comparator: Comparator 10 | ) { 11 | super(materialite, comparator, (comparator) => new Treap(comparator)); 12 | } 13 | 14 | withNewOrdering(comp: Comparator): this { 15 | const ret = new MutableSetSource(this.materialite, comp); 16 | this.materialite.materialite.tx(() => { 17 | for (const v of this.value) { 18 | ret.add(v); 19 | } 20 | }); 21 | return ret as any; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/materialite/src/sources/Source.ts: -------------------------------------------------------------------------------- 1 | import { Comparator } from "@vlcn.io/ds-and-algos/types"; 2 | import { Hoisted } from "../core/graph/Msg.js"; 3 | import { AbstractDifferenceStream } from "../core/graph/AbstractDifferenceStream.js"; 4 | 5 | export type Source = 6 | | (IStatelessSource & ISortedSource) 7 | | (IStatelessSource & IUnsortedSource) 8 | | (IStatefulSource & ISortedSource) 9 | | (IStatefulSource & IUnsortedSource); 10 | export type KeyFn = (v: T) => K; 11 | 12 | export interface ISource { 13 | readonly _state: "stateful" | "stateless"; 14 | readonly _sort: "sorted" | "unsorted"; 15 | readonly stream: AbstractDifferenceStream; 16 | /** 17 | * Detaches all pipelines from this source. 18 | */ 19 | detachPipelines(): void; 20 | destroy(): void; 21 | add(v: T): void; 22 | delete(v: T): void; 23 | } 24 | 25 | export interface ISortedSource extends ISource { 26 | readonly comparator: Comparator; 27 | readonly _sort: "sorted"; 28 | withNewOrdering(comp: Comparator): this; 29 | } 30 | 31 | export interface IUnsortedSource extends ISource { 32 | readonly keyFn: KeyFn; 33 | readonly _sort: "unsorted"; 34 | } 35 | 36 | export interface IStatelessSource extends ISource { 37 | readonly _state: "stateless"; 38 | } 39 | 40 | export interface IStatefulSource extends ISource { 41 | readonly _state: "stateful"; 42 | readonly value: CT; 43 | resendAll(msg: Hoisted): this; 44 | } 45 | 46 | // TODO: we need to understand if the ordering by which 47 | // we need data from the source matches the ordering of the 48 | // source. 49 | // TODO: do we allow dupes in a third party source or only unique values? 50 | // TODO: if only unique, comparator must be able to distinguish between 51 | // two values that are equal and not the same identity. 52 | export interface IThirdPartySource { 53 | onAdd(): void; 54 | onDelete(): void; 55 | // onReplace(): void; 56 | 57 | /** 58 | * The comparator that is used to sort the source. 59 | * If the source is already sorted by this comparator 60 | * we can avoid doing full scans over the source. 61 | * 62 | * The comparator must first compare by the ordering criteria 63 | * and then, if ordering criteria is equal, by primary key 64 | * of the data. 65 | * 66 | * @param a 67 | * @param b 68 | * @returns 69 | */ 70 | readonly comparator: Comparator; 71 | scan(options: { 72 | from?: T; 73 | limit?: number; 74 | direction?: "asc" | "desc"; 75 | }): Iterable; 76 | } 77 | 78 | // export interface ISource { 79 | // add(x: T): void; 80 | // remove(x: T): void; 81 | 82 | // readonly stream: DifferenceStream; 83 | // } 84 | -------------------------------------------------------------------------------- /packages/materialite/src/sources/__tests__/MutableSetSource.test.ts: -------------------------------------------------------------------------------- 1 | // iteratorAfter seems to return a null element without setting itself to done? 2 | // was a bug in priming. 3 | -------------------------------------------------------------------------------- /packages/materialite/src/views/ArrayView.ts: -------------------------------------------------------------------------------- 1 | import { Version } from "../core/types.js"; 2 | import { materializeMutableArray } from "./updateMutableArray.js"; 3 | import { View as View } from "./View.js"; 4 | 5 | /** 6 | * A sink that materializes a stream of differences into an array. 7 | * 8 | * This sink mutates the array in place. For immutable sinks, see: 9 | * - CopyOnWriteArraySink 10 | * - PersistentTreeSink 11 | */ 12 | export class ArrayView extends View { 13 | readonly value: T[] = []; 14 | 15 | protected run(version: Version) { 16 | let changed = false; 17 | 18 | this.reader.drain(version).forEach((collection) => { 19 | if (collection.eventMetadata?.cause === "full_recompute") { 20 | changed = this.value.length > 0; 21 | this.value.length = 0; 22 | } 23 | // now we incrementally update our sink. 24 | changed = 25 | materializeMutableArray(collection, this.value, this.comparator) || 26 | changed; 27 | }); 28 | // TODO: why is the sink called so damn often? 29 | if (changed) { 30 | this.notify(this.value, version); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/materialite/src/views/CopyOnWriteArrayView.ts: -------------------------------------------------------------------------------- 1 | import { Version } from "../core/types.js"; 2 | import { materializeMutableArray } from "./updateMutableArray.js"; 3 | import { View } from "./View.js"; 4 | 5 | /** 6 | * A sink that materializes a stream of differences into a new copy of an array. 7 | */ 8 | export class CopyOnWriteArrayView extends View { 9 | #data: readonly T[] = []; 10 | 11 | get value() { 12 | return this.#data; 13 | } 14 | 15 | protected run(version: Version) { 16 | const collections = this.reader.drain(version); 17 | if (collections.length === 0) { 18 | return; 19 | } 20 | 21 | let newData: T[]; 22 | let changed = false; 23 | newData = [...this.#data]; 24 | collections.forEach((collection) => { 25 | // now we incrementally update our sink. 26 | if (collection.eventMetadata?.cause === "full_recompute") { 27 | changed = this.#data.length > 0; 28 | newData = []; 29 | } 30 | changed = 31 | materializeMutableArray(collection, newData, this.comparator) || 32 | changed; 33 | }); 34 | this.#data = newData; 35 | if (changed) { 36 | this.notify(newData, version); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/materialite/src/views/PrimitiveView.ts: -------------------------------------------------------------------------------- 1 | import { AbstractDifferenceStream } from "../core/graph/AbstractDifferenceStream.js"; 2 | import { Version } from "../core/types.js"; 3 | import { Materialite } from "../materialite.js"; 4 | import { View } from "./View.js"; 5 | 6 | /** 7 | * Represents the most recent value from a stream of primitives. 8 | */ 9 | export class ValueView extends View { 10 | #data: T | null; 11 | 12 | constructor( 13 | materialite: Materialite, 14 | stream: AbstractDifferenceStream, 15 | initial: T | null 16 | ) { 17 | super(materialite, stream); 18 | this.#data = initial; 19 | } 20 | 21 | get value() { 22 | return this.#data; 23 | } 24 | 25 | protected run(version: Version) { 26 | const collections = this.reader.drain(version); 27 | if (collections.length === 0) { 28 | return; 29 | } 30 | 31 | const lastCollection = collections[collections.length - 1]!; 32 | // const lastValue = lastCollection.entries[lastCollection.entries.length - 1]; 33 | let lastValue = undefined; 34 | for (const [value, mult] of lastCollection.entries) { 35 | if (mult > 0) { 36 | lastValue = value; 37 | } 38 | } 39 | if (lastValue === undefined) { 40 | return; 41 | } 42 | 43 | const newData = lastValue as T; 44 | if (newData !== this.#data) { 45 | this.#data = newData; 46 | this.notify(newData, version); 47 | } else { 48 | this.notifiedListenersVersion = version; 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/materialite/src/views/__tests__/PersistentTreeView.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { Materialite } from "../../materialite.js"; 3 | import { Comparator } from "@vlcn.io/ds-and-algos/types"; 4 | 5 | const comp: Comparator = (l, r) => l - r; 6 | const m = new Materialite(); 7 | test("rematerialization", () => { 8 | const s = m.newSortedSet(comp); 9 | 10 | m.tx(() => { 11 | for (let i = 0; i < 15; ++i) { 12 | s.add(i); 13 | } 14 | }); 15 | 16 | let numProcessed = 0; 17 | const processed: number[] = []; 18 | const view = s.stream 19 | .filter((x) => { 20 | processed.push(x); 21 | ++numProcessed; 22 | return true; 23 | }) 24 | .materialize(comp, { 25 | wantInitialData: true, 26 | limit: 5, 27 | }); 28 | 29 | expect(view.value.size).toBe(5); 30 | expect([...view.value]).toEqual([0, 1, 2, 3, 4]); 31 | // we process 7 because the view eagerly takes a few more items 32 | expect(numProcessed).toBe(7); // pulling w/ limit is lazy wrt upstream operators 33 | 34 | numProcessed = 0; 35 | processed.length = 0; 36 | const newView = view.rematerialize(10); 37 | expect(newView.value.size).toBe(10); 38 | expect([...newView.value]).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); 39 | // prior view is unchanged 40 | expect([...view.value]).toEqual([0, 1, 2, 3, 4]); 41 | expect(numProcessed).toBe(7); // todo; 42 | 43 | view.destroy(); 44 | numProcessed = 0; 45 | processed.length = 0; 46 | 47 | const finalView = newView.rematerialize(15); 48 | expect([...finalView.value]).toEqual([ 49 | 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 50 | ]); 51 | expect(numProcessed).toBe(5); 52 | }); 53 | 54 | // test("rematerialization with filtered stream", () => {}); 55 | -------------------------------------------------------------------------------- /packages/materialite/src/views/__tests__/noStaleView.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { Materialite } from "../../materialite.js"; 3 | 4 | test("Attaching a new view then immeidately asking for its value returns the most up to date value", () => { 5 | const materialite = new Materialite(); 6 | const comparator = (l: number, r: number) => l - r; 7 | const set = materialite.newSortedSet(comparator); 8 | set.add(1); 9 | set.add(2); 10 | set.add(3); 11 | 12 | const view = set.stream.map((v) => v + 1).materialize(comparator); 13 | expect([...view.value]).toEqual([2, 3, 4]); 14 | }); 15 | -------------------------------------------------------------------------------- /packages/materialite/src/views/notes.md: -------------------------------------------------------------------------------- 1 | There are collections which take as input difference streams and produce views of the resulting collection under those transformations. 2 | 3 | E.g., our PlaylistView from the normalized tables of Overtone. 4 | 5 | `reduceLinear` operator that can operate on just the diff w/o history. -------------------------------------------------------------------------------- /packages/materialite/src/views/updateMutableArray.ts: -------------------------------------------------------------------------------- 1 | import { Multiset } from "../core/multiset.js"; 2 | import { binarySearch } from "@vlcn.io/ds-and-algos/binarySearch"; 3 | import { Comparator } from "@vlcn.io/ds-and-algos/types"; 4 | 5 | export function materializeMutableArray( 6 | collection: Multiset, 7 | data: T[], 8 | comparator: Comparator 9 | ): boolean { 10 | let changed = false; 11 | for (const entry of collection.entries) { 12 | let [value, mult] = entry; 13 | const idx = binarySearch(data, value, comparator); 14 | if (mult > 0) { 15 | changed = true; 16 | addAll(data, value, mult, idx); 17 | } else if (mult < 0 && idx !== -1) { 18 | changed = true; 19 | removeAll(data, value, Math.abs(mult), idx, comparator); 20 | } 21 | } 22 | 23 | return changed; 24 | } 25 | 26 | export function addAll(data: T[], value: T, mult: number, idx: number) { 27 | // add 28 | while (mult > 0) { 29 | if (idx === -1) { 30 | // add to the end 31 | data.push(value); 32 | } else { 33 | data.splice(idx, 0, value); 34 | } 35 | mult -= 1; 36 | } 37 | } 38 | 39 | export function removeAll( 40 | data: T[], 41 | value: T, 42 | mult: number, 43 | idx: number, 44 | comparator: Comparator 45 | ) { 46 | // TODO: wind back to least idx 47 | while (mult > 0) { 48 | const elem = data[idx]; 49 | if (elem === undefined || comparator(elem, value) !== 0) { 50 | break; 51 | } 52 | data.splice(idx, 1); 53 | mult -= 1; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/materialite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // project options 4 | "lib": ["ESNext", "dom"], // specifies which default set of type definitions to use ("DOM", "ES6", etc) 5 | "outDir": "dist", // .js (as well as .d.ts, .js.map, etc.) files will be emitted into this directory., 6 | "rootDir": "./src", 7 | "removeComments": true, // Strips all comments from TypeScript files when converting into JavaScript- you rarely read compiled code so this saves space 8 | "target": "ES6", // Target environment. Most modern browsers support ES6, but you may want to set it to newer or older. (defaults to ES3) 9 | 10 | // Module resolution 11 | "baseUrl": "./src", // Lets you set a base directory to resolve non-absolute module names. 12 | "esModuleInterop": true, // fixes some issues TS originally had with the ES6 spec where TypeScript treats CommonJS/AMD/UMD modules similar to ES6 module 13 | "moduleResolution": "NodeNext", // Pretty much always node for modern JS. Other option is "classic" 14 | "paths": {}, // A series of entries which re-map imports to lookup locations relative to the baseUrl 15 | "module": "NodeNext", 16 | "skipLibCheck": true, 17 | 18 | // Source Map 19 | "sourceMap": true, // enables the use of source maps for debuggers and error reporting etc 20 | 21 | // Strict Checks 22 | "alwaysStrict": true, // Ensures that your files are parsed in the ECMAScript strict mode, and emit “use strict” for each source file. 23 | "allowUnreachableCode": false, // pick up dead code paths 24 | "noImplicitAny": true, // In some cases where no type annotations are present, TypeScript will fall back to a type of any for a variable when it cannot infer the type. 25 | "strictNullChecks": true, // When strictNullChecks is true, null and undefined have their own distinct types and you’ll get a type error if you try to use them where a concrete value is expected. 26 | 27 | // Linter Checks 28 | "noImplicitReturns": true, 29 | "noUncheckedIndexedAccess": true, // accessing index must always check for undefined 30 | "noUnusedLocals": true, // Report errors on unused local variables. 31 | "noUnusedParameters": true, // Report errors on unused parameters in functions 32 | "composite": true, 33 | "incremental": true 34 | }, 35 | "include": ["./src"], 36 | "references": [{ "path": "../ds-and-algos" }] 37 | } 38 | -------------------------------------------------------------------------------- /packages/react/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @vlcn.io/materialite-react 2 | 3 | ## 2.0.2 4 | 5 | ### Patch Changes 6 | 7 | - fix error where paths that did not ask for a recompute are notified of a recompute 8 | - Updated dependencies 9 | - @vlcn.io/materialite@3.0.2 10 | 11 | ## 2.0.1 12 | 13 | ### Patch Changes 14 | 15 | - prevent tearing by emulating useEffect via useState 16 | - Updated dependencies 17 | - @vlcn.io/materialite@3.0.1 18 | 19 | ## 2.0.0 20 | 21 | ### Major Changes 22 | 23 | - auto-pull old values on attaching a view, after, take initial impls, react hooks 24 | 25 | ### Patch Changes 26 | 27 | - Updated dependencies 28 | - @vlcn.io/materialite@3.0.0 29 | -------------------------------------------------------------------------------- /packages/react/notes.md: -------------------------------------------------------------------------------- 1 | `useSignal` 2 | `useSignals` 3 | `useQuery`? 4 | `useCreateSignal`? -------------------------------------------------------------------------------- /packages/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vlcn.io/materialite-react", 3 | "version": "2.0.2", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "type": "module", 7 | "exports": { 8 | ".": "./dist/index.js" 9 | }, 10 | "module": "true", 11 | "scripts": { 12 | "test": "vitest" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "devDependencies": { 18 | "@types/react": "^18.2.15", 19 | "react": "^18.2.0", 20 | "typescript": "^5.2.2", 21 | "vitest": "^0.34.6" 22 | }, 23 | "peerDependencies": { 24 | "react": "^18.2.0" 25 | }, 26 | "dependencies": { 27 | "@vlcn.io/materialite": "workspace:*" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/react/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./hooks.js"; 2 | -------------------------------------------------------------------------------- /packages/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // project options 4 | "lib": ["ESNext", "dom"], // specifies which default set of type definitions to use ("DOM", "ES6", etc) 5 | "outDir": "dist", // .js (as well as .d.ts, .js.map, etc.) files will be emitted into this directory., 6 | "rootDir": "./src", 7 | "removeComments": true, // Strips all comments from TypeScript files when converting into JavaScript- you rarely read compiled code so this saves space 8 | "target": "ES6", // Target environment. Most modern browsers support ES6, but you may want to set it to newer or older. (defaults to ES3) 9 | 10 | // Module resolution 11 | "baseUrl": "./src", // Lets you set a base directory to resolve non-absolute module names. 12 | "esModuleInterop": true, // fixes some issues TS originally had with the ES6 spec where TypeScript treats CommonJS/AMD/UMD modules similar to ES6 module 13 | "moduleResolution": "NodeNext", // Pretty much always node for modern JS. Other option is "classic" 14 | "paths": {}, // A series of entries which re-map imports to lookup locations relative to the baseUrl 15 | "module": "NodeNext", 16 | "skipLibCheck": true, 17 | 18 | // Source Map 19 | "sourceMap": true, // enables the use of source maps for debuggers and error reporting etc 20 | 21 | // Strict Checks 22 | "alwaysStrict": true, // Ensures that your files are parsed in the ECMAScript strict mode, and emit “use strict” for each source file. 23 | "allowUnreachableCode": false, // pick up dead code paths 24 | "noImplicitAny": true, // In some cases where no type annotations are present, TypeScript will fall back to a type of any for a variable when it cannot infer the type. 25 | "strictNullChecks": true, // When strictNullChecks is true, null and undefined have their own distinct types and you’ll get a type error if you try to use them where a concrete value is expected. 26 | 27 | // Linter Checks 28 | "noImplicitReturns": true, 29 | "noUncheckedIndexedAccess": true, // accessing index must always check for undefined 30 | "noUnusedLocals": true, // Report errors on unused local variables. 31 | "noUnusedParameters": true, // Report errors on unused parameters in functions 32 | "composite": true, 33 | "incremental": true 34 | }, 35 | "include": ["./src"], 36 | "references": [{ "path": "../materialite" }] 37 | } 38 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/materialite" 3 | - "packages/ds-and-algos" 4 | - "packages/react" 5 | - "buildall" 6 | - "demos/react" 7 | - "demos/linearite" 8 | -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | - ref count so we can tear down a stream once all consumers are gone. E.g., last consumer removed from view? Remove view from ops and up the chain. 2 | - `groupBy` and sink `groupBy`. This is just reduce? -------------------------------------------------------------------------------- /vitest.workspace.ts: -------------------------------------------------------------------------------- 1 | export default ["packages/materialite", "packages/ds-and-algos"]; 2 | --------------------------------------------------------------------------------