├── .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 =
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 =
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 | setShowItems(!showItems)}
18 | >
19 |
20 | {title}
21 |
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 |
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 |
20 | {children}
21 |
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 |
71 |
72 |
73 |
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 |
78 | Post Comment
79 |
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 | {
24 | setIsOpen(false)
25 | if (onDismiss) onDismiss()
26 | }}
27 | >
28 | Cancel
29 |
30 |
31 | Delete Issue
32 |
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