├── curvilinear ├── .gitignore ├── postcss.config.js ├── src │ ├── assets │ │ ├── fonts │ │ │ ├── 27237475-28043385 │ │ │ ├── Inter-UI-Medium.woff │ │ │ ├── Inter-UI-Medium.woff2 │ │ │ ├── Inter-UI-Regular.woff │ │ │ ├── Inter-UI-ExtraBold.woff │ │ │ ├── Inter-UI-Regular.woff2 │ │ │ ├── Inter-UI-SemiBold.woff │ │ │ ├── Inter-UI-SemiBold.woff2 │ │ │ └── Inter-UI-ExtraBold.woff2 │ │ ├── icons │ │ │ ├── menu.svg │ │ │ ├── signal-strong.svg │ │ │ ├── dots.svg │ │ │ ├── signal-weak.svg │ │ │ ├── half-circle.svg │ │ │ ├── signal-medium.svg │ │ │ ├── claim.svg │ │ │ ├── circle.svg │ │ │ ├── help.svg │ │ │ ├── label.svg │ │ │ ├── rounded-claim.svg │ │ │ ├── assignee.svg │ │ │ ├── question.svg │ │ │ ├── parent-issue.svg │ │ │ ├── done.svg │ │ │ ├── delete.svg │ │ │ ├── git-issue.svg │ │ │ ├── zoom.svg │ │ │ ├── dupplication.svg │ │ │ ├── due-date.svg │ │ │ ├── cancel.svg │ │ │ ├── inbox.svg │ │ │ ├── archive.svg │ │ │ ├── attachment.svg │ │ │ ├── guide.svg │ │ │ ├── project.svg │ │ │ ├── relationship.svg │ │ │ ├── add.svg │ │ │ ├── avatar.svg │ │ │ ├── plus.svg │ │ │ ├── signal-strong.xsd │ │ │ ├── search.svg │ │ │ ├── chat.svg │ │ │ ├── issue.svg │ │ │ ├── add-subissue.svg │ │ │ ├── filter.svg │ │ │ ├── view.svg │ │ │ ├── close.svg │ │ │ ├── circle-dot.svg │ │ │ └── slack.svg │ │ └── images │ │ │ ├── convex.svg │ │ │ └── logo.svg │ ├── utils │ │ ├── date.ts │ │ ├── notification.tsx │ │ └── filterState.ts │ ├── vite-env.d.ts │ ├── components │ │ ├── PriorityIcon.tsx │ │ ├── StatusIcon.tsx │ │ ├── Portal.tsx │ │ ├── ItemGroup.tsx │ │ ├── Select.tsx │ │ ├── Toggle.tsx │ │ ├── DebugModal.tsx │ │ ├── AboutModal.tsx │ │ ├── contextmenu │ │ │ ├── StatusMenu.tsx │ │ │ ├── PriorityMenu.tsx │ │ │ ├── menu.tsx │ │ │ └── FilterMenu.tsx │ │ ├── editor │ │ │ ├── Editor.tsx │ │ │ └── EditorMenu.tsx │ │ ├── Avatar.tsx │ │ ├── ViewOptionMenu.tsx │ │ ├── Modal.tsx │ │ └── ProfileMenu.tsx │ ├── hooks │ │ ├── useLockBodyScroll.ts │ │ ├── useClickOutside.ts │ │ └── useUser.ts │ ├── local │ │ ├── types.ts │ │ ├── queries.ts │ │ └── mutations.ts │ ├── pages │ │ ├── root.tsx │ │ ├── List │ │ │ ├── IssueList.tsx │ │ │ ├── index.tsx │ │ │ └── IssueRow.tsx │ │ └── Issue │ │ │ ├── DeleteModal.tsx │ │ │ └── Comments.tsx │ ├── main.tsx │ ├── convex.ts │ ├── index.css │ ├── App.tsx │ └── types │ │ └── types.ts ├── README.md ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts ├── convex │ ├── auth.config.js │ ├── _generated │ │ ├── api.js │ │ ├── api.d.ts │ │ ├── dataModel.d.ts │ │ └── server.js │ ├── comments.ts │ ├── schema.ts │ ├── sync │ │ ├── issues.ts │ │ ├── comments.ts │ │ └── schema.ts │ ├── tsconfig.json │ ├── README.md │ └── issues.ts ├── components.json ├── index.html ├── tsconfig.app.json ├── tailwind.config.js └── package.json ├── pnpm-workspace.yaml ├── local-store ├── shared │ ├── assert.ts │ ├── indexKeys.ts │ ├── queryTokens.ts │ └── types.ts ├── tsconfig.json ├── package.json ├── browser │ ├── logger.ts │ ├── localPersistence.ts │ ├── localDbWriter.ts │ ├── network.ts │ ├── driver.ts │ ├── core │ │ ├── optimisticUpdateExecutor.ts │ │ ├── syncQueryExecutor.ts │ │ └── protocol.ts │ ├── localDbReader.ts │ └── worker │ │ └── types.ts ├── streamQuery.ts ├── server │ └── streamQuery.ts ├── react │ ├── mutationRegistry.ts │ ├── definitionFactory.ts │ ├── localDb.ts │ ├── hooks.ts │ └── LocalStoreProvider.tsx └── test │ └── browser │ └── localStore.test.ts ├── .gitignore ├── .eslintrc.cjs └── README.md /curvilinear/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .env.local 3 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'curvilinear' 3 | - 'local-store' 4 | -------------------------------------------------------------------------------- /curvilinear/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /curvilinear/src/assets/fonts/27237475-28043385: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/curvilinear/main/curvilinear/src/assets/fonts/27237475-28043385 -------------------------------------------------------------------------------- /curvilinear/src/assets/fonts/Inter-UI-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/curvilinear/main/curvilinear/src/assets/fonts/Inter-UI-Medium.woff -------------------------------------------------------------------------------- /curvilinear/src/assets/fonts/Inter-UI-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/curvilinear/main/curvilinear/src/assets/fonts/Inter-UI-Medium.woff2 -------------------------------------------------------------------------------- /curvilinear/src/assets/fonts/Inter-UI-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/curvilinear/main/curvilinear/src/assets/fonts/Inter-UI-Regular.woff -------------------------------------------------------------------------------- /curvilinear/src/assets/fonts/Inter-UI-ExtraBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/curvilinear/main/curvilinear/src/assets/fonts/Inter-UI-ExtraBold.woff -------------------------------------------------------------------------------- /curvilinear/src/assets/fonts/Inter-UI-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/curvilinear/main/curvilinear/src/assets/fonts/Inter-UI-Regular.woff2 -------------------------------------------------------------------------------- /curvilinear/src/assets/fonts/Inter-UI-SemiBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/curvilinear/main/curvilinear/src/assets/fonts/Inter-UI-SemiBold.woff -------------------------------------------------------------------------------- /curvilinear/src/assets/fonts/Inter-UI-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/curvilinear/main/curvilinear/src/assets/fonts/Inter-UI-SemiBold.woff2 -------------------------------------------------------------------------------- /curvilinear/src/assets/fonts/Inter-UI-ExtraBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/curvilinear/main/curvilinear/src/assets/fonts/Inter-UI-ExtraBold.woff2 -------------------------------------------------------------------------------- /local-store/shared/assert.ts: -------------------------------------------------------------------------------- 1 | export function assert(condition: boolean, message: string): void { 2 | if (!condition) { 3 | throw new Error(message); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /curvilinear/README.md: -------------------------------------------------------------------------------- 1 | # Convex Linear Clone 2 | 3 | This currently requires the repository to be a sibling of the `convex` monorepo. 4 | 5 | Other than that, `npm run dev` should just work. 6 | -------------------------------------------------------------------------------- /curvilinear/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 | -------------------------------------------------------------------------------- /curvilinear/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.app.json" 6 | }, 7 | { 8 | "path": "./tsconfig.node.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /curvilinear/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_CONVEX_URL: string; 5 | readonly VITE_DISABLE_INDEXED_DB: string | undefined; 6 | // more env variables... 7 | } 8 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/menu.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | !**/glob-import/dir/node_modules 2 | .DS_Store 3 | .idea 4 | *.cpuprofile 5 | *.local 6 | *.log 7 | /.vscode/ 8 | /docs/.vitepress/cache 9 | dist 10 | dist-ssr 11 | explorations 12 | node_modules 13 | playground-temp 14 | temp 15 | TODOs.md 16 | .eslintcache 17 | .vercel 18 | -------------------------------------------------------------------------------- /curvilinear/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true, 9 | "noEmit": true 10 | }, 11 | "include": ["vite.config.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /curvilinear/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import react from "@vitejs/plugin-react"; 3 | import { defineConfig } from "vite"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | "@": path.resolve(__dirname, "./src"), 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /curvilinear/convex/auth.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | providers: [ 3 | { 4 | // Configure CLERK_JWT_ISSUER_DOMAIN on the Convex Dashboard 5 | // See https://docs.convex.dev/auth/clerk#configuring-dev-and-prod-instances 6 | domain: "https://special-mackerel-86.clerk.accounts.dev", 7 | applicationID: "convex", 8 | }, 9 | ], 10 | }; 11 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/signal-strong.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/dots.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/signal-weak.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/half-circle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/signal-medium.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /curvilinear/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/claim.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /curvilinear/src/components/PriorityIcon.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { PriorityIcons } from "../types/types"; 3 | 4 | interface Props { 5 | priority: string; 6 | className?: string; 7 | } 8 | 9 | export default function PriorityIcon({ priority, className }: Props) { 10 | const classes = classNames(`w-4 h-4`, className); 11 | const Icon = PriorityIcons[priority.toLowerCase()]; 12 | return {priority}; 13 | } 14 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/circle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /curvilinear/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | CurviLinear 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /curvilinear/src/components/StatusIcon.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { StatusIcons } from "../types/types"; 3 | 4 | interface Props { 5 | status: string; 6 | className?: string; 7 | } 8 | 9 | export default function StatusIcon({ status, className }: Props) { 10 | const classes = classNames(`w-3.5 h-3.5 rounded`, className); 11 | 12 | const Icon = StatusIcons[status.toLowerCase()]; 13 | 14 | return {status}; 15 | } 16 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/help.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /curvilinear/convex/_generated/api.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { anyApi } from "convex/server"; 12 | 13 | /** 14 | * A utility for referencing Convex functions in your app's API. 15 | * 16 | * Usage: 17 | * ```js 18 | * const myFunctionReference = api.myModule.myFunction; 19 | * ``` 20 | */ 21 | export const api = anyApi; 22 | export const internal = anyApi; 23 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/label.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/rounded-claim.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/assignee.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /curvilinear/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 | -------------------------------------------------------------------------------- /local-store/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts", "**/*.js", "**/*.tsx"], 3 | "exclude": ["node_modules"], 4 | "compilerOptions": { 5 | "target": "es2020", 6 | "module": "es2020", 7 | "noEmit": true, 8 | "moduleResolution": "bundler", 9 | "isolatedModules": true, 10 | "strict": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "jsx": "react-jsx", 15 | "allowJs": true, 16 | /* allow use of internal Convex APIS */ 17 | "customConditions": ["convex-internal-types"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /curvilinear/src/local/types.ts: -------------------------------------------------------------------------------- 1 | import { sync as syncSchema } from "../../convex/sync/schema"; 2 | import { DefinitionFactory } from "local-store/react/definitionFactory"; 3 | import { DataModelFromSchemaDefinition } from "convex/server"; 4 | import { LocalDbReader, LocalDbWriter } from "local-store/react/localDb"; 5 | 6 | export type SyncDataModel = DataModelFromSchemaDefinition; 7 | 8 | export const factory = new DefinitionFactory(syncSchema); 9 | 10 | export type LocalQueryCtx = { localDb: LocalDbReader }; 11 | export type LocalMutationCtx = { localDb: LocalDbWriter }; 12 | -------------------------------------------------------------------------------- /curvilinear/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 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/question.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/parent-issue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /curvilinear/convex/comments.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { mutation } from "./_generated/server"; 3 | 4 | export const postComment = mutation({ 5 | args: { 6 | id: v.string(), 7 | username: v.string(), 8 | body: v.string(), 9 | issue_id: v.string(), 10 | created_at: v.number(), 11 | }, 12 | handler: async (ctx, args) => { 13 | const identity = await ctx.auth.getUserIdentity(); 14 | if (!identity) { 15 | throw new Error("Not logged in."); 16 | } 17 | if (identity.name !== args.username) { 18 | throw new Error("Username does not match logged in user."); 19 | } 20 | return ctx.db.insert("comments", args); 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /curvilinear/convex/schema.ts: -------------------------------------------------------------------------------- 1 | import { defineSchema, defineTable } from "convex/server"; 2 | import { v } from "convex/values"; 3 | 4 | export default defineSchema({ 5 | issues: defineTable({ 6 | id: v.string(), 7 | title: v.string(), 8 | description: v.string(), 9 | priority: v.string(), 10 | status: v.string(), 11 | modified: v.number(), 12 | created: v.number(), 13 | username: v.string(), 14 | }).index("by_issue_id", ["id"]), 15 | 16 | comments: defineTable({ 17 | id: v.string(), 18 | body: v.string(), 19 | username: v.string(), 20 | issue_id: v.string(), 21 | created_at: v.number(), 22 | }).index("by_issue_id", ["issue_id", "created_at"]), 23 | }); 24 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/done.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /local-store/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "local-store", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "build": "tsc -p ." 6 | }, 7 | "dependencies": { 8 | "async-channel": "^0.2.0", 9 | "dexie": "~4.0.10", 10 | "zod": "^3.24.0", 11 | "@radix-ui/react-icons": "~1.3.0", 12 | "@radix-ui/react-slot": "^1.0.2", 13 | "convex": "^1.23.0", 14 | "react-dom": "^18.0.0", 15 | "react": "^18.0.0", 16 | "prism-react-renderer": "1.3.5", 17 | "vitest": "^1.6.1" 18 | }, 19 | "devDependencies": { 20 | "typescript": "~5.0.3", 21 | "@types/node": "^18.17.0", 22 | "@types/node-fetch": "^2.6.1", 23 | "@types/react": "^18.0.0", 24 | "@types/react-dom": "^18.0.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /local-store/browser/logger.ts: -------------------------------------------------------------------------------- 1 | export type LogLevel = "debug" | "info" | "warn" | "error"; 2 | 3 | export class Logger { 4 | level: LogLevel = "error"; 5 | setLevel(level: LogLevel) { 6 | this.level = level; 7 | } 8 | debug(...args: any[]) { 9 | if (this.level === "debug") { 10 | console.log(`[DEBUG] ${new Date().toISOString()}`, ...args); 11 | } 12 | } 13 | info(...args: any[]) { 14 | if (["debug", "info"].includes(this.level)) { 15 | console.log(`[INFO] ${new Date().toISOString()}`, ...args); 16 | } 17 | } 18 | warn(...args: any[]) { 19 | if (["debug", "info", "warn"].includes(this.level)) { 20 | console.warn(...args); 21 | } 22 | } 23 | error(...args: any[]) { 24 | console.error(...args); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/delete.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/git-issue.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/zoom.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/dupplication.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/due-date.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/cancel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /curvilinear/convex/sync/issues.ts: -------------------------------------------------------------------------------- 1 | import { s, streamQuery } from "./schema"; 2 | 3 | const table = s.table("issues", async (ctx, id) => { 4 | const issueId = ctx.db.normalizeId("issues", id); 5 | if (!issueId) { 6 | return null; 7 | } 8 | const issue = await ctx.db.get(issueId); 9 | if (!issue) { 10 | return null; 11 | } 12 | return issue; 13 | }); 14 | 15 | export const get = table.get; 16 | 17 | export const by_issue_id = table.index( 18 | "by_issue_id", 19 | async function* (ctx, { key, inclusive, direction }) { 20 | const stream = streamQuery(ctx, { 21 | table: "issues", 22 | index: "by_issue_id", 23 | startIndexKey: [...key], 24 | startInclusive: inclusive, 25 | order: direction, 26 | }); 27 | for await (const [issue, _indexKey] of stream) { 28 | yield issue._id; 29 | } 30 | }, 31 | ); 32 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/inbox.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /curvilinear/convex/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | /* This TypeScript project config describes the environment that 3 | * Convex functions run in and is used to typecheck them. 4 | * You can modify it, but some settings required to use Convex. 5 | */ 6 | "compilerOptions": { 7 | /* These settings are not required by Convex and can be modified. */ 8 | "allowJs": true, 9 | "strict": true, 10 | "skipLibCheck": true, 11 | 12 | /* These compiler options are required by Convex */ 13 | "target": "ESNext", 14 | "lib": ["ES2021", "dom", "ESNext.Array"], 15 | "forceConsistentCasingInFileNames": true, 16 | "allowSyntheticDefaultImports": true, 17 | "module": "ESNext", 18 | "moduleResolution": "Bundler", 19 | "isolatedModules": true, 20 | "noEmit": true 21 | }, 22 | "include": ["./**/*"], 23 | "exclude": ["./_generated"] 24 | } 25 | -------------------------------------------------------------------------------- /local-store/shared/indexKeys.ts: -------------------------------------------------------------------------------- 1 | export function indexFieldsForSyncObject( 2 | syncSchema: any, 3 | table: string, 4 | index: string, 5 | ) { 6 | const indexDefinition: any = syncSchema.tables[table].indexes.find( 7 | (i: any) => i.indexDescriptor === index, 8 | ); 9 | if (!indexDefinition) { 10 | throw new Error(`Index ${index} not found for table ${table}`); 11 | } 12 | return indexDefinition.fields; 13 | } 14 | 15 | export function cursorForSyncObject( 16 | syncSchema: any, 17 | table: string, 18 | index: string, 19 | doc: any, 20 | ) { 21 | const fields = indexFieldsForSyncObject(syncSchema, table, index); 22 | // TODO: null is kind of wrong but we can't use undefined because it's not convex-json serializable 23 | return { 24 | kind: "exact" as const, 25 | value: fields.map((field: string) => doc[field] ?? null), 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/archive.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /curvilinear/convex/sync/comments.ts: -------------------------------------------------------------------------------- 1 | import { s, streamQuery } from "./schema"; 2 | 3 | const table = s.table("comments", async (ctx, id) => { 4 | const commentId = ctx.db.normalizeId("comments", id); 5 | if (!commentId) { 6 | return null; 7 | } 8 | const comment = await ctx.db.get(commentId); 9 | if (!comment) { 10 | return null; 11 | } 12 | return comment; 13 | }); 14 | 15 | export const get = table.get; 16 | 17 | export const by_issue_id = table.index( 18 | "by_issue_id", 19 | async function* (ctx, { key, inclusive, direction }) { 20 | const stream = streamQuery(ctx, { 21 | table: "comments", 22 | index: "by_issue_id", 23 | startIndexKey: [...key], 24 | startInclusive: inclusive, 25 | order: direction, 26 | }); 27 | for await (const [comment, _indexKey] of stream) { 28 | yield comment._id; 29 | } 30 | }, 31 | ); 32 | -------------------------------------------------------------------------------- /curvilinear/src/pages/root.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from "react-router-dom"; 2 | import LeftMenu from "../components/LeftMenu"; 3 | import { cssTransition, ToastContainer } from "react-toastify"; 4 | 5 | const slideUp = cssTransition({ 6 | enter: `animate__animated animate__slideInUp`, 7 | exit: `animate__animated animate__slideOutDown`, 8 | }); 9 | 10 | export default function Root() { 11 | return ( 12 |
13 |
14 | 15 | 16 |
17 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /local-store/streamQuery.ts: -------------------------------------------------------------------------------- 1 | import { streamQuery } from "../shared/pagination"; 2 | 3 | import { 4 | DataModelFromSchemaDefinition, 5 | GenericQueryCtx, 6 | SchemaDefinition, 7 | TableNamesInDataModel, 8 | } from "convex/server"; 9 | import { PageRequest } from "../shared/pagination"; 10 | 11 | export const streamQueryForServerSchema = < 12 | ServerSchema extends SchemaDefinition, 13 | >( 14 | schema: ServerSchema, 15 | ) => { 16 | return < 17 | T extends TableNamesInDataModel< 18 | DataModelFromSchemaDefinition 19 | >, 20 | >( 21 | ctx: GenericQueryCtx>, 22 | request: Omit< 23 | PageRequest, T>, 24 | "targetMaxRows" | "absoluteMaxRows" | "schema" 25 | >, 26 | ) => { 27 | return streamQuery(ctx, { ...request, schema }); 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /local-store/server/streamQuery.ts: -------------------------------------------------------------------------------- 1 | import { streamQuery } from "../shared/pagination"; 2 | 3 | import { 4 | DataModelFromSchemaDefinition, 5 | GenericQueryCtx, 6 | SchemaDefinition, 7 | TableNamesInDataModel, 8 | } from "convex/server"; 9 | import { PageRequest } from "../shared/pagination"; 10 | 11 | export const streamQueryForServerSchema = < 12 | ServerSchema extends SchemaDefinition, 13 | >( 14 | schema: ServerSchema, 15 | ) => { 16 | return < 17 | T extends TableNamesInDataModel< 18 | DataModelFromSchemaDefinition 19 | >, 20 | >( 21 | ctx: GenericQueryCtx>, 22 | request: Omit< 23 | PageRequest, T>, 24 | "targetMaxRows" | "absoluteMaxRows" | "schema" 25 | >, 26 | ) => { 27 | return streamQuery(ctx, { ...request, schema }); 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /curvilinear/src/components/ItemGroup.tsx: -------------------------------------------------------------------------------- 1 | import { BsFillCaretDownFill, BsFillCaretRightFill } from "react-icons/bs"; 2 | import * as React from "react"; 3 | import { useState } from "react"; 4 | 5 | interface Props { 6 | title: string; 7 | children: React.ReactNode; 8 | } 9 | function ItemGroup({ title, children }: Props) { 10 | const [showItems, setShowItems] = useState(true); 11 | 12 | const Icon = showItems ? BsFillCaretDownFill : BsFillCaretRightFill; 13 | return ( 14 |
15 | 22 | {showItems && children} 23 |
24 | ); 25 | } 26 | 27 | export default ItemGroup; 28 | -------------------------------------------------------------------------------- /curvilinear/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true, 24 | 25 | /* Alias */ 26 | "baseUrl": ".", 27 | "paths": { 28 | "@/*": ["./src/*"], 29 | "@cvx/*": ["./convex/*"], 30 | "~/*": ["./*"] 31 | } 32 | }, 33 | "include": ["src", "convex"] 34 | } 35 | -------------------------------------------------------------------------------- /curvilinear/src/assets/images/convex.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /curvilinear/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], 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 | -------------------------------------------------------------------------------- /curvilinear/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 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /curvilinear/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { ClerkProvider, useAuth } from "@clerk/clerk-react"; 2 | import { ConvexProviderWithClerk } from "convex/react-clerk"; 3 | import React from "react"; 4 | import ReactDOM from "react-dom/client"; 5 | import App from "./App"; 6 | import "./index.css"; 7 | import { LocalStoreProvider } from "local-store/react/LocalStoreProvider"; 8 | import { clerkPubKey, convex, localStore } from "./convex"; 9 | 10 | // Create modal root element 11 | const modalRoot = document.createElement("div"); 12 | modalRoot.id = "root-modal"; 13 | document.body.appendChild(modalRoot); 14 | 15 | ReactDOM.createRoot(document.getElementById("root")!).render( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | , 25 | ); 26 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/attachment.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /curvilinear/convex/sync/schema.ts: -------------------------------------------------------------------------------- 1 | import { defineSchema, defineTable } from "convex/server"; 2 | import { v } from "convex/values"; 3 | 4 | import { tableResolverFactory } from "local-store/server/resolvers"; 5 | import { streamQueryForServerSchema } from "local-store/server/streamQuery"; 6 | import schema from "../schema"; 7 | 8 | export const sync = defineSchema({ 9 | issues: defineTable({ 10 | id: v.string(), 11 | title: v.string(), 12 | description: v.string(), 13 | priority: v.string(), 14 | status: v.string(), 15 | modified: v.number(), 16 | created: v.number(), 17 | username: v.string(), 18 | }).index("by_issue_id", ["id"]), 19 | 20 | comments: defineTable({ 21 | id: v.string(), 22 | body: v.string(), 23 | username: v.string(), 24 | issue_id: v.string(), 25 | created_at: v.number(), 26 | }).index("by_issue_id", ["issue_id", "created_at"]), 27 | }); 28 | 29 | export const s = tableResolverFactory(sync, schema); 30 | export const streamQuery = streamQueryForServerSchema(schema); 31 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/guide.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/project.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/relationship.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /local-store/react/mutationRegistry.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DataModelFromSchemaDefinition, 3 | FunctionReference, 4 | SchemaDefinition, 5 | getFunctionName, 6 | } from "convex/server"; 7 | import { LocalDbWriter } from "./localDb"; 8 | import { LocalMutation } from "./definitionFactory"; 9 | 10 | export class MutationRegistry> { 11 | constructor(private _syncSchema: SchemaDef) {} 12 | 13 | private mutations: Record< 14 | string, 15 | { 16 | fn: FunctionReference<"mutation", "public">; 17 | optimisticUpdate: ( 18 | ctx: { 19 | localDb: LocalDbWriter>; 20 | }, 21 | args: any, 22 | ) => void; 23 | serverArgs: (args: any) => any; 24 | } 25 | > = {}; 26 | register(mutation: LocalMutation) { 27 | const name = getFunctionName(mutation.fn); 28 | if (this.mutations[name]) { 29 | throw new Error(`Mutation ${name} already registered`); 30 | } 31 | this.mutations[name] = mutation; 32 | return this; 33 | } 34 | 35 | exportToMutationMap() { 36 | return this.mutations; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/avatar.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /curvilinear/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 | -------------------------------------------------------------------------------- /curvilinear/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 | -------------------------------------------------------------------------------- /curvilinear/convex/_generated/api.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import type { 12 | ApiFromModules, 13 | FilterApi, 14 | FunctionReference, 15 | } from "convex/server"; 16 | import type * as comments from "../comments.js"; 17 | import type * as issues from "../issues.js"; 18 | import type * as sync_comments from "../sync/comments.js"; 19 | import type * as sync_issues from "../sync/issues.js"; 20 | import type * as testing from "../testing.js"; 21 | 22 | /** 23 | * A utility for referencing Convex functions in your app's API. 24 | * 25 | * Usage: 26 | * ```js 27 | * const myFunctionReference = api.myModule.myFunction; 28 | * ``` 29 | */ 30 | declare const fullApi: ApiFromModules<{ 31 | comments: typeof comments; 32 | issues: typeof issues; 33 | "sync/comments": typeof sync_comments; 34 | "sync/issues": typeof sync_issues; 35 | testing: typeof testing; 36 | }>; 37 | export declare const api: FilterApi< 38 | typeof fullApi, 39 | FunctionReference 40 | >; 41 | export declare const internal: FilterApi< 42 | typeof fullApi, 43 | FunctionReference 44 | >; 45 | -------------------------------------------------------------------------------- /curvilinear/src/hooks/useUser.ts: -------------------------------------------------------------------------------- 1 | import { useUser } from "@clerk/clerk-react"; 2 | import { useEffect } from "react"; 3 | 4 | type User = { 5 | fullName: string; 6 | imageUrl: string; 7 | } 8 | 9 | 10 | export function useCachedUser() { 11 | const { user: clerkUser } = useUser(); 12 | let user: User | undefined; 13 | if (clerkUser) { 14 | user = { 15 | fullName: clerkUser.fullName ?? "", 16 | imageUrl: clerkUser.imageUrl ?? "", 17 | }; 18 | } 19 | if (!user) { 20 | user = loadCachedUser(); 21 | } 22 | useEffect(() => { 23 | const existing = loadCachedUser(); 24 | if (clerkUser && (clerkUser.fullName !== existing?.fullName || clerkUser.imageUrl !== existing?.imageUrl)) { 25 | const newUser: User = { 26 | fullName: clerkUser.fullName ?? "", 27 | imageUrl: clerkUser.imageUrl ?? "", 28 | }; 29 | localStorage.setItem("cached-user", JSON.stringify(newUser)); 30 | } 31 | }, [clerkUser]); 32 | 33 | return user; 34 | } 35 | 36 | function loadCachedUser(): User | undefined { 37 | const existing = localStorage.getItem("cached-user"); 38 | return existing ? JSON.parse(existing) : undefined; 39 | } 40 | -------------------------------------------------------------------------------- /curvilinear/src/pages/List/IssueList.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from "react"; 2 | import { FixedSizeList as List, areEqual } from "react-window"; 3 | import { memo } from "react"; 4 | import AutoSizer from "react-virtualized-auto-sizer"; 5 | import IssueRow from "./IssueRow"; 6 | import { Issue } from "../../types/types"; 7 | 8 | export interface IssueListProps { 9 | issues: Issue[]; 10 | } 11 | 12 | function IssueList({ issues }: IssueListProps) { 13 | return ( 14 |
15 | 16 | {({ height, width }) => ( 17 | 24 | {VirtualIssueRow} 25 | 26 | )} 27 | 28 |
29 | ); 30 | } 31 | 32 | const VirtualIssueRow = memo( 33 | ({ 34 | data: issues, 35 | index, 36 | style, 37 | }: { 38 | data: Issue[]; 39 | index: number; 40 | style: CSSProperties; 41 | }) => { 42 | const issue = issues[index]; 43 | return ; 44 | }, 45 | areEqual, 46 | ); 47 | 48 | export default IssueList; 49 | -------------------------------------------------------------------------------- /curvilinear/src/convex.ts: -------------------------------------------------------------------------------- 1 | import { sync as syncSchema } from "../convex/sync/schema"; 2 | import { ConvexReactClient } from "convex/react"; 3 | import { MutationRegistry } from "local-store/react/mutationRegistry"; 4 | import { 5 | createIssue, 6 | changeStatus, 7 | changePriority, 8 | changeTitle, 9 | changeDescription, 10 | deleteIssue, 11 | postComment, 12 | } from "./local/mutations"; 13 | import { createLocalStoreClient } from "local-store/react/LocalStoreProvider"; 14 | 15 | export const clerkPubKey = 16 | "pk_test_c3BlY2lhbC1tYWNrZXJlbC04Ni5jbGVyay5hY2NvdW50cy5kZXYk"; 17 | export const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL); 18 | 19 | const mutationRegistry = new MutationRegistry(syncSchema); 20 | mutationRegistry 21 | .register(createIssue) 22 | .register(changeStatus) 23 | .register(changePriority) 24 | .register(changeTitle) 25 | .register(changeDescription) 26 | .register(deleteIssue) 27 | .register(postComment); 28 | 29 | export const localStore = createLocalStoreClient({ 30 | syncSchema, 31 | mutationRegistry, 32 | convexClient: convex, 33 | convexUrl: import.meta.env.VITE_CONVEX_URL, 34 | persistenceKey: 35 | import.meta.env.VITE_DISABLE_INDEXED_DB !== undefined 36 | ? null 37 | : "curvilinear", 38 | }); 39 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/chat.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /curvilinear/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({ 11 | isOpen, 12 | setIsOpen, 13 | onDismiss, 14 | deleteIssue, 15 | }: Props) { 16 | const handleDelete = () => { 17 | setIsOpen(false); 18 | if (onDismiss) onDismiss(); 19 | deleteIssue(); 20 | }; 21 | 22 | return ( 23 | 24 |
25 | Are you sure you want to delete this issue? 26 |
27 |
28 | 37 | 43 |
44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /curvilinear/src/pages/List/index.tsx: -------------------------------------------------------------------------------- 1 | import TopFilter from "../../components/TopFilter"; 2 | import IssueList from "./IssueList"; 3 | import { useFilterState } from "../../utils/filterState"; 4 | import { Issue } from "../../types/types"; 5 | import { loadAllIssues } from "@/local/queries"; 6 | import { useLocalQuery } from "local-store/react/hooks"; 7 | 8 | function List({ showSearch = false }) { 9 | const [filterState] = useFilterState(); 10 | const issues: Issue[] = useLocalQuery(loadAllIssues, {}) ?? []; 11 | const filteredIssues = issues.filter((issue) => { 12 | const tests = [true]; 13 | if (filterState.priority && filterState.priority.length > 0) { 14 | tests.push(filterState.priority.includes(issue.priority)); 15 | } 16 | if (filterState.status && filterState.status.length > 0) { 17 | tests.push(filterState.status.includes(issue.status)); 18 | } 19 | 20 | if (typeof filterState.query !== `undefined`) { 21 | tests.push(issue.title.includes(filterState.query)); 22 | } 23 | 24 | // Return true only if all tests are true 25 | return tests.every((test) => test); 26 | }); 27 | 28 | return ( 29 |
30 | 31 | 32 |
33 | ); 34 | } 35 | 36 | export default List; 37 | -------------------------------------------------------------------------------- /curvilinear/src/components/DebugModal.tsx: -------------------------------------------------------------------------------- 1 | import Modal from "./Modal"; 2 | 3 | interface Props { 4 | isOpen: boolean; 5 | onDismiss?: () => void; 6 | } 7 | 8 | export default function DebugModal({ 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 | {` `} 18 | built using{` `} 19 | 20 | Convex 21 | 22 | {` `}. 23 |

24 |

25 | This example is built on top of ElectricSQL's fork of the excellent 26 | clone of the Linear UI built by{` `} 27 | 28 | Tuan Nguyen 29 | 30 | . 31 |

32 |

33 | We have replaced the canned data with a stack running{` `} 34 | 35 | Convex 36 | 37 | . 38 |

39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /curvilinear/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 | {` `} 18 | built using{` `} 19 | 20 | Convex 21 | 22 | {` `}. 23 |

24 |

25 | This example is built on top of ElectricSQL's fork of the excellent 26 | clone of the Linear UI built by{` `} 27 | 28 | Tuan Nguyen 29 | 30 | . 31 |

32 |

33 | We have replaced the canned data with a stack running{` `} 34 | 35 | Convex 36 | 37 | . 38 |

39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/issue.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/add-subissue.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /local-store/browser/localPersistence.ts: -------------------------------------------------------------------------------- 1 | import { MutationInfo, PersistId } from "../shared/types"; 2 | import { CorePersistenceRequest, Page } from "./core/protocol"; 3 | 4 | export interface LocalPersistence { 5 | addListener(listener: (request: CorePersistenceRequest) => void): void; 6 | persistMutation(persistId: PersistId, mutationInfo: MutationInfo): void; 7 | persistPages(persistId: PersistId, pages: Page[]): void; 8 | } 9 | 10 | export class NoopLocalPersistence implements LocalPersistence { 11 | private listeners: Set<(request: CorePersistenceRequest) => void> = new Set(); 12 | 13 | addListener(listener: (request: CorePersistenceRequest) => void) { 14 | this.listeners.add(listener); 15 | setTimeout(() => { 16 | listener({ 17 | requestor: "LocalPersistence", 18 | kind: "ingestFromLocalPersistence", 19 | pages: [], 20 | serverTs: 0, 21 | }); 22 | }, 0); 23 | } 24 | 25 | persistMutation(persistId: PersistId, _mutationInfo: MutationInfo) { 26 | setTimeout(() => { 27 | for (const listener of this.listeners) { 28 | listener({ 29 | requestor: "LocalPersistence", 30 | kind: "localPersistComplete", 31 | persistId, 32 | }); 33 | } 34 | }, 0); 35 | } 36 | 37 | persistPages(persistId: PersistId, _pages: Page[]) { 38 | setTimeout(() => { 39 | for (const listener of this.listeners) { 40 | listener({ 41 | requestor: "LocalPersistence", 42 | kind: "localPersistComplete", 43 | persistId, 44 | }); 45 | } 46 | }, 0); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/filter.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /curvilinear/src/local/queries.ts: -------------------------------------------------------------------------------- 1 | import { factory } from "./types"; 2 | 3 | export const preload = factory.defineLocalQuery((ctx) => { 4 | const issues = ctx.localDb.query("issues").withIndex("by_issue_id").collect(); 5 | const allComments = []; 6 | for (const issue of issues) { 7 | const comments = ctx.localDb 8 | .query("comments") 9 | .withIndex("by_issue_id", (q: any) => q.eq("issue_id", issue.id)) 10 | .collect(); 11 | allComments.push(...comments); 12 | } 13 | let length = 0; 14 | for (const issue of issues) { 15 | length += JSON.stringify(issue).length; 16 | } 17 | for (const comment of allComments) { 18 | length += JSON.stringify(comment).length; 19 | } 20 | console.log( 21 | `Preloaded ${issues.length} issues and ${allComments.length} comments (${(length / 1024).toFixed(2)}KB).`, 22 | ); 23 | return []; 24 | }, "preload"); 25 | 26 | export const loadAllIssues = factory.defineLocalQuery((ctx) => { 27 | return ctx.localDb.query("issues").withIndex("by_issue_id").collect(); 28 | }, "loadAllIssues"); 29 | 30 | export const getIssueById = factory.defineLocalQuery( 31 | (ctx, args: { id: string }) => { 32 | // XXX: `.eq()` type expectes undefined. 33 | return ctx.localDb 34 | .query("issues") 35 | .withIndex("by_issue_id", (q) => q.eq("id", args.id as any)) 36 | .unique(); 37 | }, 38 | "getIssueById", 39 | ); 40 | 41 | export const loadComments = factory.defineLocalQuery( 42 | (ctx, args: { issue_id: string }) => { 43 | return ctx.localDb 44 | .query("comments") 45 | .withIndex("by_issue_id", (q: any) => q.eq("issue_id", args.issue_id)) 46 | .collect(); 47 | }, 48 | "loadComments", 49 | ); 50 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/view.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /curvilinear/src/index.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 | -------------------------------------------------------------------------------- /curvilinear/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/types"; 5 | import { Menu } from "./menu"; 6 | 7 | interface Props { 8 | id: string; 9 | button: ReactNode; 10 | className?: string; 11 | onSelect?: (item: unknown) => void; 12 | } 13 | export default function StatusMenu({ id, button, className, onSelect }: Props) { 14 | const [keyword, setKeyword] = useState(``); 15 | const handleSelect = (status: string) => { 16 | if (onSelect) onSelect(status); 17 | }; 18 | 19 | let statuses = StatusOptions; 20 | if (keyword !== ``) { 21 | const normalizedKeyword = keyword.toLowerCase().trim(); 22 | statuses = statuses.filter( 23 | ([_icon, _id, l]) => l.toLowerCase().indexOf(normalizedKeyword) !== -1, 24 | ); 25 | } 26 | 27 | const options = statuses.map(([svg, id, label]) => { 28 | return ( 29 | handleSelect(id)}> 30 | {id} 31 |
{label}
32 |
33 | ); 34 | }); 35 | 36 | return ( 37 | <> 38 | 39 | {button} 40 | 41 | 42 | 43 | setKeyword(kw)} 50 | > 51 | {options} 52 | 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true, node: true }, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended-type-checked", 7 | "plugin:react-hooks/recommended", 8 | ], 9 | ignorePatterns: [ 10 | "dist", 11 | "convex/_generated", 12 | ".eslintrc.cjs", 13 | "tailwind.config.js", 14 | // There are currently ESLint errors in shadcn/ui 15 | "src/components/ui", 16 | ], 17 | parser: "@typescript-eslint/parser", 18 | parserOptions: { 19 | project: "./tsconfig.app.json", 20 | tsconfigRootDir: __dirname, 21 | }, 22 | plugins: ["react-refresh"], 23 | rules: { 24 | "react-refresh/only-export-components": [ 25 | "warn", 26 | { allowConstantExport: true }, 27 | ], 28 | 29 | // All of these overrides ease getting into 30 | // TypeScript, and can be removed for stricter 31 | // linting down the line. 32 | 33 | // Only warn on unused variables, and ignore variables starting with `_` 34 | "@typescript-eslint/no-unused-vars": [ 35 | "warn", 36 | { varsIgnorePattern: "^_", argsIgnorePattern: "^_" }, 37 | ], 38 | 39 | // Allow escaping the compiler 40 | "@typescript-eslint/ban-ts-comment": "error", 41 | 42 | // Allow explicit `any`s 43 | "@typescript-eslint/no-explicit-any": "off", 44 | 45 | // START: Allow implicit `any`s 46 | "@typescript-eslint/no-unsafe-argument": "off", 47 | "@typescript-eslint/no-unsafe-assignment": "off", 48 | "@typescript-eslint/no-unsafe-call": "off", 49 | "@typescript-eslint/no-unsafe-member-access": "off", 50 | "@typescript-eslint/no-unsafe-return": "off", 51 | // END: Allow implicit `any`s 52 | 53 | // Allow async functions without await 54 | // for consistency (esp. Convex `handler`s) 55 | "@typescript-eslint/require-await": "off", 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /curvilinear/convex/_generated/dataModel.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated data model types. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import type { 12 | DataModelFromSchemaDefinition, 13 | DocumentByName, 14 | TableNamesInDataModel, 15 | SystemTableNames, 16 | } from "convex/server"; 17 | import type { GenericId } from "convex/values"; 18 | import schema from "../schema.js"; 19 | 20 | /** 21 | * The names of all of your Convex tables. 22 | */ 23 | export type TableNames = TableNamesInDataModel; 24 | 25 | /** 26 | * The type of a document stored in Convex. 27 | * 28 | * @typeParam TableName - A string literal type of the table name (like "users"). 29 | */ 30 | export type Doc = DocumentByName< 31 | DataModel, 32 | TableName 33 | >; 34 | 35 | /** 36 | * An identifier for a document in Convex. 37 | * 38 | * Convex documents are uniquely identified by their `Id`, which is accessible 39 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). 40 | * 41 | * Documents can be loaded using `db.get(id)` in query and mutation functions. 42 | * 43 | * IDs are just strings at runtime, but this type can be used to distinguish them from other 44 | * strings when type checking. 45 | * 46 | * @typeParam TableName - A string literal type of the table name (like "users"). 47 | */ 48 | export type Id = 49 | GenericId; 50 | 51 | /** 52 | * A type describing your Convex data model. 53 | * 54 | * This type includes information about what tables you have, the type of 55 | * documents stored in those tables, and the indexes defined on them. 56 | * 57 | * This type is used to parameterize methods like `queryGeneric` and 58 | * `mutationGeneric` to make them type-safe. 59 | */ 60 | export type DataModel = DataModelFromSchemaDefinition; 61 | -------------------------------------------------------------------------------- /curvilinear/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/types"; 6 | 7 | interface Props { 8 | id: string; 9 | button: ReactNode; 10 | filterKeyword?: boolean; 11 | className?: string; 12 | onSelect?: (item: string) => void; 13 | } 14 | 15 | function PriorityMenu({ 16 | id, 17 | button, 18 | filterKeyword = false, 19 | className, 20 | onSelect, 21 | }: Props) { 22 | const [keyword, setKeyword] = useState(``); 23 | 24 | const handleSelect = (priority: string) => { 25 | setKeyword(``); 26 | if (onSelect) onSelect(priority); 27 | }; 28 | let statusOpts = PriorityOptions; 29 | if (keyword !== ``) { 30 | const normalizedKeyword = keyword.toLowerCase().trim(); 31 | statusOpts = statusOpts.filter( 32 | ([_Icon, _priority, label]) => 33 | (label as string).toLowerCase().indexOf(normalizedKeyword) !== -1, 34 | ); 35 | } 36 | 37 | const options = statusOpts.map(([svg, priority, label], idx) => { 38 | return ( 39 | handleSelect(priority as string)} 42 | > 43 | {label} 44 | 45 | ); 46 | }); 47 | 48 | return ( 49 | <> 50 | 51 | {button} 52 | 53 | 54 | 55 | setKeyword(kw)} 61 | className={className} 62 | > 63 | {options} 64 | 65 | 66 | 67 | ); 68 | } 69 | 70 | export default PriorityMenu; 71 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /local-store/shared/queryTokens.ts: -------------------------------------------------------------------------------- 1 | import { FunctionReference, getFunctionName } from "convex/server"; 2 | import { ConvexSubscriptionId, IndexName, TableName } from "./types"; 3 | import { convexToJson } from "convex/values"; 4 | import { Value } from "convex/values"; 5 | 6 | export function createQueryToken( 7 | path: FunctionReference, 8 | args: any, 9 | ): ConvexSubscriptionId { 10 | return serializePathAndArgs( 11 | getFunctionName(path), 12 | args, 13 | ) as ConvexSubscriptionId; 14 | } 15 | 16 | export function canonicalizeUdfPath(udfPath: string): string { 17 | const pieces = udfPath.split(":"); 18 | let moduleName: string; 19 | let functionName: string; 20 | if (pieces.length === 1) { 21 | moduleName = pieces[0]; 22 | functionName = "default"; 23 | } else { 24 | moduleName = pieces.slice(0, pieces.length - 1).join(":"); 25 | functionName = pieces[pieces.length - 1]; 26 | } 27 | if (moduleName.endsWith(".js")) { 28 | moduleName = moduleName.slice(0, -3); 29 | } 30 | return `${moduleName}:${functionName}`; 31 | } 32 | 33 | /** 34 | * A string representing the name and arguments of a query. 35 | * 36 | * This is used by the {@link BaseConvexClient}. 37 | * 38 | * @public 39 | */ 40 | export type QueryToken = string; 41 | 42 | export function serializePathAndArgs( 43 | udfPath: string, 44 | args: Record, 45 | ): QueryToken { 46 | return JSON.stringify({ 47 | udfPath: canonicalizeUdfPath(udfPath), 48 | args: convexToJson(args), 49 | }); 50 | } 51 | 52 | export const parseIndexNameAndTableName = ( 53 | token: QueryToken, 54 | ): { 55 | indexName: IndexName; 56 | tableName: TableName; 57 | } | null => { 58 | const { udfPath } = JSON.parse(token); 59 | const [filePath, indexName] = udfPath.split(":"); 60 | const filePathParts = filePath.split("/"); 61 | if (filePathParts.length < 2) { 62 | return null; 63 | } 64 | const tableName = filePathParts.at(-1); 65 | const syncPart = filePathParts.at(-2); 66 | if (syncPart !== "sync") { 67 | return null; 68 | } 69 | return { indexName, tableName }; 70 | }; 71 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/circle-dot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /curvilinear/src/components/editor/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useEditor, 3 | EditorContent, 4 | BubbleMenu, 5 | type Extensions, 6 | } from "@tiptap/react"; 7 | import StarterKit from "@tiptap/starter-kit"; 8 | import Placeholder from "@tiptap/extension-placeholder"; 9 | import Table from "@tiptap/extension-table"; 10 | import TableCell from "@tiptap/extension-table-cell"; 11 | import TableHeader from "@tiptap/extension-table-header"; 12 | import TableRow from "@tiptap/extension-table-row"; 13 | import { Markdown } from "tiptap-markdown"; 14 | import EditorMenu from "./EditorMenu"; 15 | import { useEffect, useRef } from "react"; 16 | 17 | interface EditorProps { 18 | value: string; 19 | onChange: (value: string) => void; 20 | className?: string; 21 | placeholder?: string; 22 | } 23 | 24 | const Editor = ({ 25 | value, 26 | onChange, 27 | className = ``, 28 | placeholder, 29 | }: EditorProps) => { 30 | const editorProps = { 31 | attributes: { 32 | class: className, 33 | }, 34 | }; 35 | const markdownValue = useRef(null); 36 | 37 | const extensions: Extensions = [ 38 | StarterKit, 39 | Markdown, 40 | Table, 41 | TableRow, 42 | TableHeader, 43 | TableCell, 44 | ]; 45 | 46 | const editor = useEditor({ 47 | extensions, 48 | editorProps, 49 | content: value || undefined, 50 | onUpdate: ({ editor }) => { 51 | markdownValue.current = editor.storage.markdown.getMarkdown(); 52 | onChange(markdownValue.current || ``); 53 | }, 54 | }); 55 | 56 | useEffect(() => { 57 | if (editor && markdownValue.current !== value) { 58 | editor.commands.setContent(value); 59 | } 60 | }, [value]); 61 | 62 | if (placeholder) { 63 | extensions.push( 64 | Placeholder.configure({ 65 | placeholder, 66 | }), 67 | ); 68 | } 69 | 70 | return ( 71 | <> 72 | 73 | {editor && ( 74 | 75 | 76 | 77 | )} 78 | 79 | ); 80 | }; 81 | 82 | export default Editor; 83 | -------------------------------------------------------------------------------- /local-store/browser/localDbWriter.ts: -------------------------------------------------------------------------------- 1 | import { GenericDocument } from "convex/server"; 2 | import { IndexRangeRequest } from "../shared/types"; 3 | 4 | import { IndexRangeBounds, TableName } from "../shared/types"; 5 | import { GenericId } from "convex/values"; 6 | import { LocalDbReaderImpl } from "./localDbReader"; 7 | 8 | export class LocalDbWriterImpl extends LocalDbReaderImpl { 9 | public debugIndexRanges: Map< 10 | string, 11 | { 12 | table: string; 13 | index: string; 14 | indexRangeBounds: IndexRangeBounds; 15 | order: "asc" | "desc"; 16 | limit: number; 17 | } 18 | > = new Map(); 19 | private recordWrite: ( 20 | tableName: TableName, 21 | id: GenericId, 22 | doc: GenericDocument | null, 23 | ) => void; 24 | constructor( 25 | syncSchema: any, 26 | requestRange: (rangeRequest: IndexRangeRequest) => Array, 27 | loadObject: ( 28 | table: TableName, 29 | id: GenericId, 30 | ) => GenericDocument | null, 31 | recordWrite: ( 32 | tableName: TableName, 33 | id: GenericId, 34 | doc: GenericDocument | null, 35 | ) => void, 36 | ) { 37 | super(syncSchema, requestRange, loadObject); 38 | this.recordWrite = recordWrite; 39 | } 40 | 41 | insert( 42 | tableName: TableName, 43 | id: GenericId, 44 | doc: GenericDocument | null, 45 | ) { 46 | this.recordWrite(tableName, id, { 47 | ...doc, 48 | _creationTime: Date.now(), 49 | _id: id, 50 | }); 51 | } 52 | 53 | delete(tableName: TableName, id: GenericId) { 54 | this.recordWrite(tableName, id, null); 55 | } 56 | 57 | replace(tableName: TableName, id: GenericId, doc: GenericDocument) { 58 | this.recordWrite(tableName, id, doc); 59 | } 60 | 61 | patch(tableName: TableName, id: GenericId, update: GenericDocument) { 62 | const existing = this.get(tableName, id); 63 | if (existing === null) { 64 | throw new Error("Object not found"); 65 | } 66 | this.recordWrite(tableName, id, { 67 | ...existing, 68 | ...update, 69 | }); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /local-store/react/definitionFactory.ts: -------------------------------------------------------------------------------- 1 | import { SchemaDefinition } from "convex/server"; 2 | import { DefaultFunctionArgs, FunctionReference } from "convex/server"; 3 | import { LocalDbReader, LocalDbWriter } from "./localDb"; 4 | import { DataModelFromSchemaDefinition } from "convex/server"; 5 | import { Value } from "convex/values"; 6 | 7 | export interface LocalMutation< 8 | ServerArgs extends DefaultFunctionArgs, 9 | OptimisticUpdateArgs extends DefaultFunctionArgs = ServerArgs, 10 | > { 11 | fn: FunctionReference<"mutation", "public", ServerArgs>; 12 | optimisticUpdate: ( 13 | ctx: { localDb: LocalDbWriter }, 14 | args: OptimisticUpdateArgs, 15 | ) => void; 16 | serverArgs: (args: OptimisticUpdateArgs) => ServerArgs; 17 | __localMutation: true; 18 | } 19 | 20 | export interface LocalQuery { 21 | handler: (ctx: { localDb: LocalDbReader }, args: Args) => T; 22 | debugName?: string; 23 | __localQuery: true; 24 | } 25 | 26 | export class DefinitionFactory> { 27 | constructor(private syncSchema: SchemaDef) {} 28 | 29 | defineLocalMutation< 30 | ServerArgs extends DefaultFunctionArgs, 31 | OptimisticUpdateArgs extends DefaultFunctionArgs = ServerArgs, 32 | >( 33 | fn: FunctionReference<"mutation", "public", ServerArgs>, 34 | optimisticUpdate: ( 35 | ctx: { localDb: LocalDbWriter> }, 36 | args: OptimisticUpdateArgs, 37 | ) => void, 38 | serverArgs?: (args: OptimisticUpdateArgs) => ServerArgs, 39 | ): LocalMutation { 40 | return { 41 | fn, 42 | optimisticUpdate, 43 | serverArgs: 44 | serverArgs ?? 45 | ((args: OptimisticUpdateArgs) => args as unknown as ServerArgs), 46 | __localMutation: true, 47 | }; 48 | } 49 | 50 | defineLocalQuery( 51 | f: ( 52 | ctx: { localDb: LocalDbReader> }, 53 | args: Args, 54 | ) => T, 55 | debugName?: string, 56 | ): LocalQuery { 57 | return { 58 | handler: f, 59 | debugName, 60 | __localQuery: true, 61 | }; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /curvilinear/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | container: { 6 | center: true, 7 | padding: "2rem", 8 | screens: { 9 | "2xl": "1400px", 10 | }, 11 | }, 12 | extend: { 13 | colors: { 14 | border: "hsl(var(--border))", 15 | input: "hsl(var(--input))", 16 | ring: "hsl(var(--ring))", 17 | background: "hsl(var(--background))", 18 | foreground: "hsl(var(--foreground))", 19 | primary: { 20 | DEFAULT: "hsl(var(--primary))", 21 | foreground: "hsl(var(--primary-foreground))", 22 | }, 23 | secondary: { 24 | DEFAULT: "hsl(var(--secondary))", 25 | foreground: "hsl(var(--secondary-foreground))", 26 | }, 27 | destructive: { 28 | DEFAULT: "hsl(var(--destructive))", 29 | foreground: "hsl(var(--destructive-foreground))", 30 | }, 31 | muted: { 32 | DEFAULT: "hsl(var(--muted))", 33 | foreground: "hsl(var(--muted-foreground))", 34 | }, 35 | accent: { 36 | DEFAULT: "hsl(var(--accent))", 37 | foreground: "hsl(var(--accent-foreground))", 38 | }, 39 | popover: { 40 | DEFAULT: "hsl(var(--popover))", 41 | foreground: "hsl(var(--popover-foreground))", 42 | }, 43 | card: { 44 | DEFAULT: "hsl(var(--card))", 45 | foreground: "hsl(var(--card-foreground))", 46 | }, 47 | }, 48 | borderRadius: { 49 | lg: "var(--radius)", 50 | md: "calc(var(--radius) - 2px)", 51 | sm: "calc(var(--radius) - 4px)", 52 | }, 53 | keyframes: { 54 | "accordion-down": { 55 | from: { height: 0 }, 56 | to: { height: "var(--radix-accordion-content-height)" }, 57 | }, 58 | "accordion-up": { 59 | from: { height: "var(--radix-accordion-content-height)" }, 60 | to: { height: 0 }, 61 | }, 62 | }, 63 | animation: { 64 | "accordion-down": "accordion-down 0.2s ease-out", 65 | "accordion-up": "accordion-up 0.2s ease-out", 66 | }, 67 | }, 68 | }, 69 | plugins: [require("tailwindcss-animate")], 70 | }; 71 | -------------------------------------------------------------------------------- /curvilinear/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 |
11 | 12 | 13 | 14 | 15 | 16 | {title} 17 |
18 | )} 19 |
{msg}
20 |
21 | ); 22 | toast(content, { 23 | position: `bottom-right`, 24 | }); 25 | } 26 | 27 | export function showInfo(msg: string, title: string = ``) { 28 | //TODO: make notification showing from bottom 29 | const content = ( 30 |
31 | {title !== `` && ( 32 |
35 | 36 | 37 | 38 | 39 | 40 | {title} 41 |
42 | )} 43 |
{msg}
44 |
45 | ); 46 | toast(content, { 47 | position: `bottom-right`, 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /local-store/react/localDb.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DocumentByInfo, 3 | DocumentByName, 4 | FilterBuilder, 5 | GenericDataModel, 6 | GenericTableInfo, 7 | IndexNames, 8 | IndexRange, 9 | IndexRangeBuilder, 10 | NamedIndex, 11 | NamedTableInfo, 12 | TableNamesInDataModel, 13 | WithOptionalSystemFields, 14 | } from "convex/server"; 15 | import { GenericId } from "convex/values"; 16 | 17 | export interface LocalDbReader { 18 | get>( 19 | table: T, 20 | id: GenericId, 21 | ): DocumentByName | null; 22 | query>( 23 | table: T, 24 | ): QueryInitializer>; 25 | } 26 | 27 | interface QueryInitializer 28 | extends Query { 29 | withIndex>( 30 | indexName: IndexName, 31 | builder?: ( 32 | q: IndexRangeBuilder< 33 | DocumentByInfo, 34 | NamedIndex 35 | >, 36 | ) => IndexRange, 37 | ): Query; 38 | } 39 | 40 | interface Query { 41 | collect(): DocumentByInfo[]; 42 | take(n: number): DocumentByInfo[]; 43 | unique(): DocumentByInfo | null; 44 | first(): DocumentByInfo | null; 45 | filter( 46 | filter: (q: FilterBuilder) => FilterBuilder, 47 | ): Query; 48 | order(order: "asc" | "desc"): Query; 49 | } 50 | 51 | export interface LocalDbWriter 52 | extends LocalDbReader { 53 | insert>( 54 | tableName: T, 55 | id: string, 56 | document: WithOptionalSystemFields>, 57 | ): GenericId; 58 | delete>( 59 | tableName: T, 60 | id: string, 61 | ): void; 62 | replace>( 63 | tableName: T, 64 | id: string, 65 | document: WithOptionalSystemFields>, 66 | ): void; 67 | patch>( 68 | tableName: T, 69 | id: string, 70 | document: Partial>, 71 | ): void; 72 | } 73 | -------------------------------------------------------------------------------- /local-store/react/hooks.ts: -------------------------------------------------------------------------------- 1 | import { Value, convexToJson } from "convex/values"; 2 | import { useLayoutEffect, useMemo, useState } from "react"; 3 | import { useLocalStoreClient } from "./LocalStoreProvider"; 4 | import { SyncQueryResult } from "../shared/types"; 5 | import { DefaultFunctionArgs } from "convex/server"; 6 | import { LocalQuery, LocalMutation } from "./definitionFactory"; 7 | 8 | export function useLocalQuery< 9 | T extends Value, 10 | Args extends DefaultFunctionArgs, 11 | >( 12 | localQuery: LocalQuery, 13 | args: Args, 14 | debugName?: string, 15 | ): T | undefined { 16 | const localClient = useLocalStoreClient(); 17 | const argsJson = JSON.stringify( 18 | convexToJson((args ?? {}) as unknown as Value), 19 | ); 20 | // eslint-disable-next-line react-hooks/exhaustive-deps 21 | const stableArgs = useMemo(() => args, [argsJson]); 22 | const [result, setResult] = useState({ kind: "loading" }); 23 | const fn = localQuery.handler; 24 | const fnDebugName = localQuery.debugName; 25 | const fullDebugName = [fnDebugName, debugName] 26 | .filter((d) => d !== undefined) 27 | .join(":"); 28 | // By using `useLayoutEffect`, we can guarantee that we don't externalize a loading 29 | // state and cause a flicker if the data is ready locally. 30 | useLayoutEffect(() => { 31 | const syncQuerySubscriptionId = localClient.addSyncQuery( 32 | fn, 33 | stableArgs, 34 | (r) => { 35 | setResult(r); 36 | }, 37 | fullDebugName, 38 | ); 39 | return () => { 40 | localClient.removeSyncQuery(syncQuerySubscriptionId); 41 | }; 42 | }, [localClient, fn, stableArgs, fullDebugName]); 43 | if (result.kind === "loaded") { 44 | if (result.status === "success") { 45 | return result.value as T; 46 | } else { 47 | throw result.error; 48 | } 49 | } else { 50 | return undefined; 51 | } 52 | } 53 | 54 | // This isn't really used right now in favor of `localClient.mutation` 55 | export function useLocalMutation< 56 | OptUpdateArgs extends DefaultFunctionArgs, 57 | ServerArgs extends DefaultFunctionArgs, 58 | >( 59 | mutation: LocalMutation, 60 | ): (args: OptUpdateArgs) => Promise { 61 | const localClient = useLocalStoreClient(); 62 | return async (args: OptUpdateArgs) => { 63 | await localClient.mutation(mutation, args as any); 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /curvilinear/src/assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /curvilinear/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) || []) 27 | .join(``) 28 | .slice(0, 2) 29 | .toUpperCase(); 30 | if (acr.length === 1) { 31 | acr = acr + name.slice(1, 2).toLowerCase(); 32 | } 33 | return acr; 34 | } 35 | function Avatar({ online, showOffline, name, onClick, avatarUrl }: Props) { 36 | let avatar, status; 37 | 38 | // create avatar image icon 39 | // XXX: Sync users everywhere. 40 | if (avatarUrl && false) 41 | avatar = ( 42 | {name} 48 | ); 49 | else if (name !== undefined) { 50 | // use name as avatar 51 | avatar = ( 52 |
56 | {getAcronym(name)} 57 |
58 | ); 59 | } else { 60 | // try to use default avatar 61 | avatar = ( 62 | avatar 63 | ); 64 | } 65 | 66 | //status icon 67 | if (online || showOffline) 68 | status = ( 69 | // 70 | 79 | ); 80 | else status = null; 81 | 82 | return ( 83 |
84 | {avatar} 85 | {status} 86 |
87 | ); 88 | } 89 | 90 | export default Avatar; 91 | -------------------------------------------------------------------------------- /curvilinear/src/local/mutations.ts: -------------------------------------------------------------------------------- 1 | import { api } from "../../convex/_generated/api"; 2 | import { FunctionArgs } from "convex/server"; 3 | import { getIssueById } from "./queries"; 4 | import { factory } from "./types"; 5 | 6 | export const createIssue = factory.defineLocalMutation( 7 | api.issues.createIssue, 8 | (ctx, args: FunctionArgs) => { 9 | ctx.localDb.insert("issues", args.id, args); 10 | }, 11 | ); 12 | 13 | export const changeStatus = factory.defineLocalMutation( 14 | api.issues.changeStatus, 15 | (ctx, args: FunctionArgs) => { 16 | const issue = getIssueById.handler(ctx, { id: args.id }); 17 | if (!issue) { 18 | throw new Error("Issue not found"); 19 | } 20 | ctx.localDb.replace("issues", args.id, { 21 | ...issue, 22 | status: args.status, 23 | }); 24 | }, 25 | ); 26 | 27 | export const changePriority = factory.defineLocalMutation( 28 | api.issues.changePriority, 29 | (ctx, args: FunctionArgs) => { 30 | const issue = getIssueById.handler(ctx, { id: args.id }); 31 | if (!issue) { 32 | throw new Error("Issue not found"); 33 | } 34 | ctx.localDb.replace("issues", args.id, { 35 | ...issue, 36 | priority: args.priority, 37 | }); 38 | }, 39 | ); 40 | 41 | export const changeTitle = factory.defineLocalMutation( 42 | api.issues.changeTitle, 43 | (ctx, args: FunctionArgs) => { 44 | const issue = getIssueById.handler(ctx, { id: args.id }); 45 | if (!issue) { 46 | throw new Error("Issue not found"); 47 | } 48 | ctx.localDb.replace("issues", args.id, { 49 | ...issue, 50 | title: args.title, 51 | }); 52 | }, 53 | ); 54 | 55 | export const changeDescription = factory.defineLocalMutation( 56 | api.issues.changeDescription, 57 | (ctx, args: FunctionArgs) => { 58 | const issue = getIssueById.handler(ctx, { id: args.id }); 59 | if (!issue) { 60 | throw new Error("Issue not found"); 61 | } 62 | ctx.localDb.replace("issues", args.id, { 63 | ...issue, 64 | title: args.description, 65 | }); 66 | }, 67 | ); 68 | 69 | export const deleteIssue = factory.defineLocalMutation( 70 | api.issues.deleteIssue, 71 | (ctx, args: FunctionArgs) => { 72 | ctx.localDb.delete("issues", args.id); 73 | }, 74 | ); 75 | 76 | export const postComment = factory.defineLocalMutation( 77 | api.comments.postComment, 78 | (ctx, args: FunctionArgs) => { 79 | ctx.localDb.insert("comments", args.id, args); 80 | }, 81 | ); 82 | -------------------------------------------------------------------------------- /curvilinear/src/pages/List/IssueRow.tsx: -------------------------------------------------------------------------------- 1 | import type { CSSProperties } from "react"; 2 | import PriorityMenu from "../../components/contextmenu/PriorityMenu"; 3 | import StatusMenu from "../../components/contextmenu/StatusMenu"; 4 | import PriorityIcon from "../../components/PriorityIcon"; 5 | import StatusIcon from "../../components/StatusIcon"; 6 | import Avatar from "../../components/Avatar"; 7 | import { memo } from "react"; 8 | import { useNavigate } from "react-router-dom"; 9 | import { formatDate } from "../../utils/date"; 10 | import { Issue } from "../../types/types"; 11 | import { useLocalStoreClient } from "local-store/react/LocalStoreProvider"; 12 | import { changePriority, changeStatus } from "@/local/mutations"; 13 | 14 | interface Props { 15 | issue: Issue; 16 | style: CSSProperties; 17 | } 18 | 19 | function IssueRow({ issue, style }: Props) { 20 | const navigate = useNavigate(); 21 | const client = useLocalStoreClient(); 22 | 23 | const handleChangeStatus = async (status: string) => { 24 | const args = { 25 | id: issue.id, 26 | status, 27 | }; 28 | await client.mutation(changeStatus, args); 29 | }; 30 | 31 | const handleChangePriority = async (priority: string) => { 32 | const args = { 33 | id: issue.id, 34 | priority, 35 | }; 36 | await client.mutation(changePriority, args); 37 | }; 38 | 39 | return ( 40 |
navigate(`/issue/${issue.id}`)} 45 | style={style} 46 | > 47 |
48 | } 51 | onSelect={handleChangePriority} 52 | /> 53 |
54 |
55 | } 58 | onSelect={handleChangeStatus as any} 59 | /> 60 |
61 |
62 | {issue.title.slice(0, 3000) || ``} 63 |
64 |
65 | {formatDate(new Date(issue.created))} 66 |
67 |
68 | 69 |
70 |
71 | ); 72 | } 73 | 74 | export default memo(IssueRow); 75 | -------------------------------------------------------------------------------- /curvilinear/convex/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your Convex functions directory! 2 | 3 | Write your Convex functions here. 4 | See https://docs.convex.dev/functions for more. 5 | 6 | A query function that takes two arguments looks like: 7 | 8 | ```ts 9 | // functions.js 10 | import { query } from "./_generated/server"; 11 | import { v } from "convex/values"; 12 | 13 | export const myQueryFunction = query({ 14 | // Validators for arguments. 15 | args: { 16 | first: v.number(), 17 | second: v.string(), 18 | }, 19 | 20 | // Function implementation. 21 | handler: async (ctx, args) => { 22 | // Read the database as many times as you need here. 23 | // See https://docs.convex.dev/database/reading-data. 24 | const documents = await ctx.db.query("tablename").collect(); 25 | 26 | // Arguments passed from the client are properties of the args object. 27 | console.log(args.first, args.second); 28 | 29 | // Write arbitrary JavaScript here: filter, aggregate, build derived data, 30 | // remove non-public properties, or create new objects. 31 | return documents; 32 | }, 33 | }); 34 | ``` 35 | 36 | Using this query function in a React component looks like: 37 | 38 | ```ts 39 | const data = useQuery(api.functions.myQueryFunction, { 40 | first: 10, 41 | second: "hello", 42 | }); 43 | ``` 44 | 45 | A mutation function looks like: 46 | 47 | ```ts 48 | // functions.js 49 | import { mutation } from "./_generated/server"; 50 | import { v } from "convex/values"; 51 | 52 | export const myMutationFunction = mutation({ 53 | // Validators for arguments. 54 | args: { 55 | first: v.string(), 56 | second: v.string(), 57 | }, 58 | 59 | // Function implementation. 60 | handler: async (ctx, args) => { 61 | // Insert or modify documents in the database here. 62 | // Mutations can also read from the database like queries. 63 | // See https://docs.convex.dev/database/writing-data. 64 | const message = { body: args.first, author: args.second }; 65 | const id = await ctx.db.insert("messages", message); 66 | 67 | // Optionally, return a value from your mutation. 68 | return await ctx.db.get(id); 69 | }, 70 | }); 71 | ``` 72 | 73 | Using this mutation function in a React component looks like: 74 | 75 | ```ts 76 | const mutation = useMutation(api.functions.myMutationFunction); 77 | function handleButtonPress() { 78 | // fire and forget, the most common way to use mutations 79 | mutation({ first: "Hello!", second: "me" }); 80 | // OR 81 | // use the result once the mutation has completed 82 | mutation({ first: "Hello!", second: "me" }).then((result) => 83 | console.log(result), 84 | ); 85 | } 86 | ``` 87 | 88 | Use the Convex CLI to push your functions to a deployment. See everything 89 | the Convex CLI can do by running `npx convex -h` in your project root 90 | directory. To learn more, launch the docs with `npx convex docs`. 91 | -------------------------------------------------------------------------------- /curvilinear/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { SignInButton } from "@clerk/clerk-react"; 2 | import { Unauthenticated } from "convex/react"; 3 | import "animate.css/animate.min.css"; 4 | import { useState, createContext } from "react"; 5 | import { createBrowserRouter, RouterProvider } from "react-router-dom"; 6 | import "react-toastify/dist/ReactToastify.css"; 7 | import List from "./pages/List"; 8 | import Root from "./pages/root"; 9 | import Issue from "./pages/Issue"; 10 | import { localStore } from "./convex"; 11 | import { preload } from "./local/queries"; 12 | import { useCachedUser } from "./hooks/useUser"; 13 | 14 | interface MenuContextInterface { 15 | showMenu: boolean; 16 | setShowMenu: (show: boolean) => void; 17 | } 18 | 19 | export const MenuContext = createContext(null as MenuContextInterface | null); 20 | 21 | const router = createBrowserRouter([ 22 | { 23 | path: `/`, 24 | element: , 25 | loader: async () => { 26 | console.time(`preload`); 27 | await new Promise((resolve, reject) => { 28 | const subscriptionId = localStore.addSyncQuery( 29 | preload.handler, 30 | {}, 31 | (result) => { 32 | if (result.kind === "loading") { 33 | return; 34 | } 35 | // XXX: Have the layer above pass in `subscriptionId` to the callback. 36 | queueMicrotask(() => localStore.removeSyncQuery(subscriptionId)); 37 | if (result.status === "success") { 38 | resolve(result); 39 | } else { 40 | reject(result.error); 41 | } 42 | }, 43 | "preload", 44 | ); 45 | }); 46 | console.timeEnd(`preload`); 47 | return null; 48 | }, 49 | children: [ 50 | { 51 | path: `/`, 52 | element: , 53 | }, 54 | { 55 | path: `/search`, 56 | element: , 57 | }, 58 | { 59 | path: `/issue/:id`, 60 | element: , 61 | }, 62 | ], 63 | }, 64 | ]); 65 | 66 | const App = () => { 67 | const [showMenu, setShowMenu] = useState(false); 68 | // Prepopulate the user in local storage if the user is signed in. 69 | useCachedUser(); 70 | return ( 71 |
72 |
73 | 74 | 75 | 81 | 82 | 83 |
84 | 85 | 86 | 87 |
88 | ); 89 | }; 90 | 91 | export default App; 92 | -------------------------------------------------------------------------------- /curvilinear/src/components/contextmenu/menu.tsx: -------------------------------------------------------------------------------- 1 | import classnames from "classnames"; 2 | import { ReactNode, useRef } from "react"; 3 | import { 4 | ContextMenu, 5 | MenuItem, 6 | type MenuItemProps as CMMenuItemProps, 7 | } from "@firefox-devtools/react-contextmenu"; 8 | 9 | const sizeClasses = { 10 | small: `w-34`, 11 | normal: `w-72`, 12 | }; 13 | 14 | export interface MenuProps { 15 | id: string; 16 | size: keyof typeof sizeClasses; 17 | className?: string; 18 | onKeywordChange?: (kw: string) => void; 19 | filterKeyword: boolean; 20 | children: ReactNode; 21 | searchPlaceholder?: string; 22 | } 23 | 24 | interface MenuItemProps { 25 | children: ReactNode; 26 | onClick?: CMMenuItemProps[`onClick`]; 27 | } 28 | const Item = function ({ onClick, children }: MenuItemProps) { 29 | return ( 30 | 34 | {children} 35 | 36 | ); 37 | }; 38 | 39 | const Divider = function () { 40 | return ; 41 | }; 42 | 43 | const Header = function ({ children }: MenuItemProps) { 44 | return ( 45 | 46 | {children} 47 | 48 | ); 49 | }; 50 | 51 | export const Menu = (props: MenuProps) => { 52 | const { 53 | id, 54 | size = `small`, 55 | onKeywordChange, 56 | children, 57 | className, 58 | filterKeyword, 59 | searchPlaceholder, 60 | } = props; 61 | const ref = useRef(null); 62 | 63 | const classes = classnames( 64 | `cursor-default bg-white rounded shadow-modal z-100`, 65 | sizeClasses[size], 66 | className, 67 | ); 68 | 69 | return ( 70 | { 75 | if (ref.current) ref.current.focus(); 76 | }} 77 | > 78 |
{ 80 | e.stopPropagation(); 81 | }} 82 | > 83 | {filterKeyword && ( 84 | { 88 | if (onKeywordChange) onKeywordChange(e.target.value); 89 | }} 90 | onClick={(e) => { 91 | e.stopPropagation(); 92 | }} 93 | placeholder={searchPlaceholder} 94 | /> 95 | )} 96 | {children} 97 |
98 |
99 | ); 100 | }; 101 | 102 | Menu.Item = Item; 103 | Menu.Divider = Divider; 104 | Menu.Header = Header; 105 | -------------------------------------------------------------------------------- /curvilinear/src/utils/filterState.ts: -------------------------------------------------------------------------------- 1 | import { useSearchParams } from "react-router-dom"; 2 | 3 | interface FilterState { 4 | orderBy: string; 5 | orderDirection: `asc` | `desc`; 6 | status?: string[]; 7 | priority?: string[]; 8 | query?: string; 9 | } 10 | 11 | export function useFilterState(): [ 12 | FilterState, 13 | (state: Partial) => void, 14 | ] { 15 | const [searchParams, setSearchParams] = useSearchParams(); 16 | const orderBy = searchParams.get(`orderBy`) ?? `created`; 17 | const orderDirection = 18 | (searchParams.get(`orderDirection`) as `asc` | `desc`) ?? `desc`; 19 | const status = searchParams 20 | .getAll(`status`) 21 | .map((status) => status.toLocaleLowerCase().split(`,`)) 22 | .flat(); 23 | const priority = searchParams 24 | .getAll(`priority`) 25 | .map((status) => status.toLocaleLowerCase().split(`,`)) 26 | .flat(); 27 | const query = searchParams.get(`query`); 28 | 29 | const state = { 30 | orderBy, 31 | orderDirection, 32 | status, 33 | priority, 34 | query: query || undefined, 35 | }; 36 | 37 | const setState = (state: Partial) => { 38 | const { orderBy, orderDirection, status, priority, query } = state; 39 | setSearchParams((searchParams) => { 40 | if (orderBy) { 41 | searchParams.set(`orderBy`, orderBy); 42 | } else { 43 | searchParams.delete(`orderBy`); 44 | } 45 | if (orderDirection) { 46 | searchParams.set(`orderDirection`, orderDirection); 47 | } else { 48 | searchParams.delete(`orderDirection`); 49 | } 50 | if (status && status.length > 0) { 51 | searchParams.set(`status`, status.join(`,`)); 52 | } else { 53 | searchParams.delete(`status`); 54 | } 55 | if (priority && priority.length > 0) { 56 | searchParams.set(`priority`, priority.join(`,`)); 57 | } else { 58 | searchParams.delete(`priority`); 59 | } 60 | if (query) { 61 | searchParams.set(`query`, query); 62 | } else { 63 | searchParams.delete(`query`); 64 | } 65 | return searchParams; 66 | }); 67 | }; 68 | 69 | return [state, setState]; 70 | } 71 | 72 | interface FilterStateWhere { 73 | status?: { in: string[] }; 74 | priority?: { in: string[] }; 75 | title?: { contains: string }; 76 | OR?: [{ title: { contains: string } }, { description: { contains: string } }]; 77 | } 78 | 79 | export function filterStateToWhere(filterState: FilterState) { 80 | const { status, priority, query } = filterState; 81 | const where: FilterStateWhere = {}; 82 | if (status && status.length > 0) { 83 | where.status = { in: status }; 84 | } 85 | if (priority && priority.length > 0) { 86 | where.priority = { in: priority }; 87 | } 88 | if (query) { 89 | where.OR = [ 90 | { title: { contains: query } }, 91 | { description: { contains: query } }, 92 | ]; 93 | } 94 | return where; 95 | } 96 | -------------------------------------------------------------------------------- /local-store/browser/network.ts: -------------------------------------------------------------------------------- 1 | import { BaseConvexClient, FunctionResult, QueryToken } from "convex/browser"; 2 | import { 3 | ConvexSubscriptionId, 4 | MutationId, 5 | MutationInfo, 6 | PageArguments, 7 | SyncFunction, 8 | } from "../shared/types"; 9 | import { getFunctionName } from "convex/server"; 10 | 11 | type Unsubscribe = () => void; 12 | 13 | export interface Network { 14 | sendQueryToNetwork(syncFunction: SyncFunction, args: PageArguments): void; 15 | removeQueryFromNetwork(queriesToRemove: ConvexSubscriptionId[]): void; 16 | sendMutationToNetwork(mutationInfo: MutationInfo): void; 17 | getMutationId(requestId: number): MutationId | null; 18 | convexClient: BaseConvexClient; 19 | addOnTransitionHandler(handler: (transition: Transition) => void): void; 20 | } 21 | 22 | export class NetworkImpl implements Network { 23 | private subscriptions: Map = new Map(); 24 | private mutationMapping: Map< 25 | number, 26 | { 27 | mutationId: MutationId; 28 | result: Promise; 29 | } 30 | > = new Map(); 31 | 32 | convexClient: BaseConvexClient; 33 | constructor(opts: { convexClient: BaseConvexClient }) { 34 | this.convexClient = opts.convexClient; 35 | } 36 | 37 | sendQueryToNetwork(syncFunction: SyncFunction, args: PageArguments): void { 38 | const { queryToken, unsubscribe } = this.convexClient.subscribe( 39 | getFunctionName(syncFunction), 40 | args as any, 41 | ); 42 | this.subscriptions.set(queryToken as ConvexSubscriptionId, unsubscribe); 43 | } 44 | 45 | removeQueryFromNetwork(queriesToRemove: ConvexSubscriptionId[]): void { 46 | for (const query of queriesToRemove) { 47 | const unsubscribe = this.subscriptions.get(query); 48 | if (unsubscribe) { 49 | unsubscribe(); 50 | } 51 | this.subscriptions.delete(query); 52 | } 53 | } 54 | 55 | sendMutationToNetwork(mutationInfo: MutationInfo): void { 56 | const { requestId, mutationPromise } = this.convexClient.enqueueMutation( 57 | getFunctionName(mutationInfo.mutationPath), 58 | mutationInfo.serverArgs as any, 59 | ); 60 | this.mutationMapping.set(requestId, { 61 | mutationId: mutationInfo.mutationId, 62 | result: mutationPromise, 63 | }); 64 | } 65 | 66 | getMutationId(requestId: number): MutationId | null { 67 | const mutation = this.mutationMapping.get(requestId); 68 | if (!mutation) { 69 | return null; 70 | } 71 | return mutation.mutationId; 72 | } 73 | 74 | addOnTransitionHandler(handler: (transition: Transition) => void) { 75 | this.convexClient.addOnTransitionHandler(handler); 76 | } 77 | } 78 | 79 | // TODO: Import these from convex 80 | type QueryModification = 81 | // `undefined` generally comes from an optimistic update setting the query to be loading 82 | { kind: "Updated"; result: FunctionResult | undefined } | { kind: "Removed" }; 83 | 84 | type Transition = { 85 | queries: Array<{ token: QueryToken; modification: QueryModification }>; 86 | reflectedMutations: Array<{ requestId: any; result: FunctionResult }>; 87 | timestamp: any; 88 | }; 89 | -------------------------------------------------------------------------------- /curvilinear/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "linear-convex", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "npm-run-all --parallel dev:frontend dev:backend", 8 | "dev:frontend": "vite", 9 | "dev:backend": "convex dev", 10 | "build": "tsc && vite build", 11 | "lint": "tsc && eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 12 | "format": "prettier --write .", 13 | "preview": "vite preview" 14 | }, 15 | "dependencies": { 16 | "@clerk/clerk-react": "^4.25.2", 17 | "@firefox-devtools/react-contextmenu": "^5.1.1", 18 | "@headlessui/react": "^1.7.17", 19 | "@svgr/plugin-jsx": "^8.1.0", 20 | "@svgr/plugin-svgo": "^8.1.0", 21 | "@tailwindcss/forms": "^0.5.6", 22 | "@tiptap/extension-placeholder": "^2.4.0", 23 | "@tiptap/extension-table": "^2.4.0", 24 | "@tiptap/extension-table-cell": "^2.4.0", 25 | "@tiptap/extension-table-header": "^2.4.0", 26 | "@tiptap/extension-table-row": "^2.4.0", 27 | "@tiptap/pm": "^2.4.0", 28 | "@tiptap/react": "^2.4.0", 29 | "@tiptap/starter-kit": "^2.4.0", 30 | "animate.css": "^4.1.1", 31 | "classnames": "^2.5.1", 32 | "clsx": "^2.0.0", 33 | "convex": "^1.23.0", 34 | "convex-helpers": "^0.1.79", 35 | "dayjs": "^1.11.11", 36 | "dotenv": "^16.4.5", 37 | "fractional-indexing": "^3.2.0", 38 | "jsonwebtoken": "^9.0.2", 39 | "local-store": "workspace:*", 40 | "lodash.debounce": "^4.0.8", 41 | "openai": "^4.76.2", 42 | "react": "^18.3.1", 43 | "react-beautiful-dnd": "^13.1.1", 44 | "react-dom": "^18.3.1", 45 | "react-icons": "^4.10.1", 46 | "react-markdown": "^8.0.7", 47 | "react-router-dom": "^6.24.1", 48 | "react-toastify": "^9.1.3", 49 | "react-virtualized-auto-sizer": "^1.0.24", 50 | "react-window": "^1.8.10", 51 | "tailwind-merge": "^1.14.0", 52 | "tailwindcss-animate": "^1.0.7", 53 | "tiptap-markdown": "^0.8.2", 54 | "uuid": "^9.0.0", 55 | "vite-plugin-svgr": "^3.2.0", 56 | "zod": "^3.24.1" 57 | }, 58 | "devDependencies": { 59 | "@tailwindcss/typography": "^0.5.10", 60 | "@types/jest": "^29.5.12", 61 | "@types/lodash.debounce": "^4.0.9", 62 | "@types/node": "^20.14.10", 63 | "@types/react": "^18.3.3", 64 | "@types/react-beautiful-dnd": "^13.1.8", 65 | "@types/react-dom": "^18.3.0", 66 | "@types/react-router-dom": "^5.3.3", 67 | "@types/react-window": "^1.8.8", 68 | "@types/uuid": "^9.0.3", 69 | "@types/vite-plugin-react-svg": "^0.2.5", 70 | "@typescript-eslint/eslint-plugin": "^6.0.0", 71 | "@typescript-eslint/parser": "^6.0.0", 72 | "@vitejs/plugin-react": "^4.3.1", 73 | "autoprefixer": "^10.4.19", 74 | "dotenv-cli": "^7.4.2", 75 | "eslint": "^8.57.0", 76 | "eslint-config-prettier": "^9.1.0", 77 | "eslint-plugin-prettier": "^5.1.3", 78 | "eslint-plugin-react-hooks": "^4.6.2", 79 | "eslint-plugin-react-refresh": "^0.4.3", 80 | "fs-extra": "^10.0.0", 81 | "npm-run-all": "^4.1.5", 82 | "postcss": "^8.4.39", 83 | "prettier": "^3.3.2", 84 | "tailwindcss": "^3.4.4", 85 | "typescript": "^5.5.3", 86 | "vite": "^4.4.5" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /curvilinear/src/components/ViewOptionMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Transition } from "@headlessui/react"; 2 | import { useClickOutside } from "../hooks/useClickOutside"; 3 | import { useRef } from "react"; 4 | import Select from "./Select"; 5 | import { useFilterState } from "../utils/filterState"; 6 | 7 | interface Props { 8 | isOpen: boolean; 9 | onDismiss?: () => void; 10 | } 11 | export default function ({ isOpen, onDismiss }: Props) { 12 | const ref = useRef(null); 13 | const [filterState, setFilterState] = useFilterState(); 14 | 15 | useClickOutside(ref, () => { 16 | if (isOpen && onDismiss) onDismiss(); 17 | }); 18 | 19 | const handleOrderByChange = (e: React.ChangeEvent) => { 20 | setFilterState({ 21 | ...filterState, 22 | orderBy: e.target.value, 23 | }); 24 | }; 25 | 26 | const handleOrderDirectionChange = ( 27 | e: React.ChangeEvent, 28 | ) => { 29 | setFilterState({ 30 | ...filterState, 31 | orderDirection: e.target.value as `asc` | `desc`, 32 | }); 33 | }; 34 | 35 | return ( 36 |
37 | 47 |
48 | View Options 49 |
50 | 51 |
52 | {/*
53 | Grouping 54 |
55 | 62 |
63 |
*/} 64 | 65 |
66 | Ordering 67 |
68 | 77 |
78 |
79 | 86 |
87 |
88 |
89 |
90 |
91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Convex local sync + Linear clone 2 | 3 | This repo is an alpha of the offline sync engine for Convex. See a deployed version of the demo [here](https://linear-convex.vercel.app/). 4 | 5 | ## Installation 6 | This monorepo depends on [PNPM](https://pnpm.io/installation) for package management. 7 | 8 | ```bash 9 | pnpm i 10 | cd curvilinear 11 | npm run dev 12 | ``` 13 | 14 | Note that you can either [use a local dev server](https://stack.convex.dev/anonymous-development) without signing up for a Convex account or use a managed 15 | cloud deployment for running the backend. 16 | 17 | ## App 18 | 19 | Vite serves the app on port 5173. It's pretty minimal Linear clone that demonstrates: 20 | 21 | 1. Fast preloading of a page's content on initial load without any spinners 22 | 2. Storing application state in IndexedDB for offline support 23 | 3. Fully local client navigations that don't block on the server and show a spinner. 24 | 4. Mutations for creating, editing, and deleting tasks. 25 | 26 | ### Schema 27 | 28 | The server-side schema is defined at `convex/schema.ts`. We have two tables, `issues` 29 | and `comments`. 30 | 31 | The local schema is defined as a *projection* of the server-side schema at 32 | `convex/sync/schema.ts`. There are two local tables, `issues` and `comments`, and their 33 | relationship to the underlying server tables are at `convex/sync/{issues,comments}.ts`. 34 | 35 | We currently don't have any non-trivial mapping logic between the two data models, 36 | so the logic in `convex/sync/issues.ts` just directly maps queries to the local table 37 | to queries to the underlying sync table. 38 | 39 | ### Queries 40 | 41 | On the client, the app's queries against the local store are specified in 42 | `src/local/queries.ts`. These synchronous callbacks issue queries against a 43 | `ctx.localDb` object, and the sync engine handles pulling in data from the 44 | server to fulfill these queries. 45 | 46 | React app components, like `src/pages/Issue/Comments.tsx` use the `useLocalQuery` 47 | hook to execute one of these callbacks. The hook returns `undefined` if the data 48 | isn't available yet, which may happen if not all relevant data was preloaded. 49 | 50 | ### Mutations 51 | 52 | The client specifies its mutations in `src/local/mutations.ts`. These are centrally 53 | defined since the sync engine needs to know how to replay in-progress mutations 54 | on restart. 55 | 56 | Mutations contain both a pointer to an authoritative server-side mutation (e.g. 57 | `api.issues.changeStatus`) as well as a callback for mutating the local store. These 58 | don't have to agree, and the sync engine will coordinate atomically rolling back 59 | the local store update once the server-side change is complete and visible. 60 | 61 | Server-side mutations are fully serializable, so a wide range of conflict resolution 62 | strategies are possible. 63 | 64 | ## Known issues 65 | 66 | 1. Correctness. We have deterministic simulation testing set up but haven't exercised it and wrung out all the bugs yet. 67 | 2. Scale and performance. We've not optimized this for large numbers of rows managed yet. 68 | 3. Making the website part offline with PWA 69 | 4. API for reactively querying the set of in-progress mutations 70 | 5. Native client ID allocation 71 | 6. Synchronization across multiple tabs 72 | 7. Versioning and local database migrations 73 | 8. The naming isn't great, and the APIs need a lot of polish. 74 | 9. Codesharing between local optimistic updates and the authoritative server mutation. 75 | -------------------------------------------------------------------------------- /curvilinear/src/components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | memo, 3 | RefObject, 4 | useCallback, 5 | useRef, 6 | type MouseEvent, 7 | } from "react"; 8 | import ReactDOM from "react-dom"; 9 | import classnames from "classnames"; 10 | 11 | import CloseIconImg from "../assets/icons/close.svg"; 12 | import useLockBodyScroll from "../hooks/useLockBodyScroll"; 13 | import { Transition } from "@headlessui/react"; 14 | 15 | interface Props { 16 | title?: string; 17 | isOpen: boolean; 18 | center?: boolean; 19 | className?: string; 20 | /* function called when modal is closed */ 21 | onDismiss?: () => void; 22 | children?: React.ReactNode; 23 | size?: keyof typeof sizeClasses; 24 | } 25 | const sizeClasses = { 26 | large: `w-400`, 27 | normal: `w-140`, 28 | }; 29 | 30 | function Modal({ 31 | title, 32 | isOpen, 33 | center = true, 34 | size = `normal`, 35 | className, 36 | onDismiss, 37 | children, 38 | }: Props) { 39 | const ref = useRef(null) as RefObject; 40 | const outerRef = useRef(null); 41 | 42 | const wrapperClasses = classnames( 43 | `fixed flex flex-col items-center inset-0 z-50`, 44 | { 45 | "justify-center": center, 46 | }, 47 | ); 48 | const modalClasses = classnames( 49 | `flex flex-col items-center overflow-hidden transform bg-white modal shadow-large-modal rounded-xl border-gray-200`, 50 | { 51 | "mt-20 mb-2 ": !center, 52 | }, 53 | sizeClasses[size], 54 | className, 55 | ); 56 | const handleClick = useCallback((event: MouseEvent) => { 57 | event.stopPropagation(); 58 | event.preventDefault(); 59 | if (!onDismiss) return; 60 | if (ref.current && !ref.current.contains(event.target as Element)) { 61 | onDismiss(); 62 | } 63 | }, []); 64 | 65 | useLockBodyScroll(); 66 | 67 | const modal = ( 68 |
69 | 79 | {/* XXX: Why don't the tailwind classes set above apply? */} 80 |
88 | {title && ( 89 |
90 |
{title}
91 |
92 | Close 97 |
98 |
99 | )} 100 | {children} 101 |
102 |
103 |
104 | ); 105 | 106 | return ReactDOM.createPortal( 107 | modal, 108 | document.getElementById(`root-modal`) as Element, 109 | ); 110 | } 111 | 112 | export default memo(Modal); 113 | -------------------------------------------------------------------------------- /curvilinear/src/types/types.ts: -------------------------------------------------------------------------------- 1 | import CancelIconImg from "../assets/icons/cancel.svg"; 2 | import BacklogIconImg from "../assets/icons/circle-dot.svg"; 3 | import TodoIconImg from "../assets/icons/circle.svg"; 4 | import DoneIconImg from "../assets/icons/done.svg"; 5 | import InProgressIconImg from "../assets/icons/half-circle.svg"; 6 | import HighPriorityIconImg from "../assets/icons/signal-strong.svg"; 7 | import LowPriorityIconImg from "../assets/icons/signal-weak.svg"; 8 | import MediumPriorityIconImg from "../assets/icons/signal-medium.svg"; 9 | import NoPriorityIconImg from "../assets/icons/dots.svg"; 10 | import UrgentPriorityIconImg from "../assets/icons/rounded-claim.svg"; 11 | 12 | export type Issue = { 13 | id: string; 14 | title: string; 15 | description: string; 16 | priority: (typeof Priority)[keyof typeof Priority]; 17 | status: (typeof Status)[keyof typeof Status]; 18 | modified: number; 19 | created: number; 20 | username: string; 21 | }; 22 | 23 | export type Comment = { 24 | id: string; 25 | body: string; 26 | username: string; 27 | issue_id: string; 28 | created_at: number; 29 | }; 30 | 31 | export const Priority = { 32 | NONE: `none`, 33 | URGENT: `urgent`, 34 | HIGH: `high`, 35 | LOW: `low`, 36 | MEDIUM: `medium`, 37 | }; 38 | 39 | export const PriorityDisplay = { 40 | [Priority.NONE]: `None`, 41 | [Priority.URGENT]: `Urgent`, 42 | [Priority.HIGH]: `High`, 43 | [Priority.LOW]: `Low`, 44 | [Priority.MEDIUM]: `Medium`, 45 | }; 46 | 47 | export const PriorityIcons = { 48 | [Priority.NONE]: NoPriorityIconImg, 49 | [Priority.URGENT]: UrgentPriorityIconImg, 50 | [Priority.HIGH]: HighPriorityIconImg, 51 | [Priority.MEDIUM]: MediumPriorityIconImg, 52 | [Priority.LOW]: LowPriorityIconImg, 53 | }; 54 | 55 | export const PriorityOptions: [ 56 | string, 57 | string, 58 | (typeof Priority)[keyof typeof Priority], 59 | ][] = [ 60 | [PriorityIcons[Priority.NONE], Priority.NONE, `None`], 61 | [PriorityIcons[Priority.URGENT], Priority.URGENT, `Urgent`], 62 | [PriorityIcons[Priority.HIGH], Priority.HIGH, `High`], 63 | [PriorityIcons[Priority.MEDIUM], Priority.MEDIUM, `Medium`], 64 | [PriorityIcons[Priority.LOW], Priority.LOW, `Low`], 65 | ]; 66 | 67 | export const Status = { 68 | BACKLOG: `backlog`, 69 | TODO: `todo`, 70 | IN_PROGRESS: `in_progress`, 71 | DONE: `done`, 72 | CANCELED: `canceled`, 73 | }; 74 | 75 | export const StatusDisplay = { 76 | [Status.BACKLOG]: `Backlog`, 77 | [Status.TODO]: `To Do`, 78 | [Status.IN_PROGRESS]: `In Progress`, 79 | [Status.DONE]: `Done`, 80 | [Status.CANCELED]: `Canceled`, 81 | }; 82 | 83 | export const StatusIcons = { 84 | [Status.BACKLOG]: BacklogIconImg, 85 | [Status.TODO]: TodoIconImg, 86 | [Status.IN_PROGRESS]: InProgressIconImg, 87 | [Status.DONE]: DoneIconImg, 88 | [Status.CANCELED]: CancelIconImg, 89 | }; 90 | 91 | export const StatusOptions: [ 92 | string, 93 | (typeof Status)[keyof typeof Status], 94 | string, 95 | ][] = [ 96 | [StatusIcons[Status.BACKLOG], Status.BACKLOG, StatusDisplay[Status.BACKLOG]], 97 | [StatusIcons[Status.TODO], Status.TODO, StatusDisplay[Status.TODO]], 98 | [ 99 | StatusIcons[Status.IN_PROGRESS], 100 | Status.IN_PROGRESS, 101 | StatusDisplay[Status.IN_PROGRESS], 102 | ], 103 | [StatusIcons[Status.DONE], Status.DONE, StatusDisplay[Status.DONE]], 104 | [ 105 | StatusIcons[Status.CANCELED], 106 | Status.CANCELED, 107 | StatusDisplay[Status.CANCELED], 108 | ], 109 | ]; 110 | -------------------------------------------------------------------------------- /local-store/test/browser/localStore.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { CopyOnWriteLocalStore } from "../../browser/core/localStore"; 3 | import { sync as syncSchema } from "../../../simulation/convex/sync/schema"; 4 | import { anyApi, defineSchema, defineTable } from "convex/server"; 5 | import { v } from "convex/values"; 6 | import { Writes } from "../../browser/core/protocol"; 7 | import { 8 | LOG2_PAGE_SIZE, 9 | SingleIndexRangeExecutor, 10 | indexRangeUnbounded, 11 | } from "../../browser/core/paginator"; 12 | import { createQueryToken } from "../../shared/queryTokens"; 13 | import { PageArguments, PageResult } from "../../shared/types"; 14 | 15 | export const sync = defineSchema({ 16 | // specific to current user 17 | conversations: defineTable({ 18 | _id: v.string(), 19 | latestMessageTime: v.number(), 20 | emoji: v.optional(v.string()), 21 | users: v.array(v.id("users")), 22 | hasUnreadMessages: v.boolean(), 23 | }).index("by_priority", ["hasUnreadMessages", "latestMessageTime"]), 24 | }); 25 | 26 | test("applyWrites", async () => { 27 | const localStore = new CopyOnWriteLocalStore(syncSchema); 28 | const pageArguments: PageArguments = { 29 | syncTableName: "conversations", 30 | index: "by_priority", 31 | target: { kind: "successor", value: [] }, 32 | log2PageSize: LOG2_PAGE_SIZE, 33 | }; 34 | const pageResult: PageResult = { 35 | results: [], 36 | lowerBound: { kind: "predecessor", value: [] }, 37 | upperBound: { kind: "successor", value: [] }, 38 | }; 39 | localStore.ingest([ 40 | { 41 | tableName: "conversations", 42 | indexName: "by_priority", 43 | convexSubscriptionId: createQueryToken( 44 | anyApi.sync.conversations.by_priority, 45 | pageArguments, 46 | ), 47 | state: { 48 | kind: "loaded", 49 | value: pageResult, 50 | }, 51 | }, 52 | ]); 53 | const writesA = new Writes(); 54 | writesA.set("conversations", "1" as any, { 55 | _id: "1", 56 | latestMessageTime: 1, 57 | hasUnreadMessages: true, 58 | emoji: "A", 59 | users: [], 60 | }); 61 | localStore.applyWrites(writesA); 62 | const paginator = new SingleIndexRangeExecutor( 63 | { 64 | tableName: "conversations", 65 | indexName: "by_priority", 66 | indexRangeBounds: indexRangeUnbounded, 67 | count: 100, 68 | order: "desc", 69 | }, 70 | syncSchema, 71 | localStore, 72 | ); 73 | const result = paginator.tryFulfill(); 74 | expect(result.state).toEqual("fulfilled"); 75 | 76 | const results = 77 | result.state === "fulfilled" ? result.results.map((r) => r.emoji) : []; 78 | expect(results).toEqual(["A"]); 79 | 80 | const writesB = new Writes(); 81 | writesB.set("conversations", "2" as any, { 82 | _id: "2", 83 | latestMessageTime: 2, 84 | hasUnreadMessages: true, 85 | emoji: "B", 86 | users: [], 87 | }); 88 | localStore.applyWrites(writesB); 89 | const paginatorB = new SingleIndexRangeExecutor( 90 | { 91 | tableName: "conversations", 92 | indexName: "by_priority", 93 | indexRangeBounds: indexRangeUnbounded, 94 | count: 100, 95 | order: "desc", 96 | }, 97 | syncSchema, 98 | localStore, 99 | ); 100 | const resultB = paginatorB.tryFulfill(); 101 | expect(resultB.state).toEqual("fulfilled"); 102 | const resultsB = 103 | resultB.state === "fulfilled" ? resultB.results.map((r) => r.emoji) : []; 104 | expect(resultsB).toEqual(["B", "A"]); 105 | }); 106 | -------------------------------------------------------------------------------- /curvilinear/convex/issues.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { DatabaseReader, mutation } from "./_generated/server"; 3 | 4 | export const createIssue = mutation({ 5 | args: { 6 | id: v.string(), 7 | title: v.string(), 8 | description: v.string(), 9 | priority: v.string(), 10 | status: v.string(), 11 | modified: v.number(), 12 | created: v.number(), 13 | username: v.string(), 14 | }, 15 | handler: async (ctx, args) => { 16 | const identity = await ctx.auth.getUserIdentity(); 17 | if (!identity) { 18 | throw new Error("Not logged in."); 19 | } 20 | if (identity.name !== args.username) { 21 | throw new Error("Username does not match logged in user."); 22 | } 23 | await ctx.db.insert("issues", args); 24 | }, 25 | }); 26 | 27 | export const changeStatus = mutation({ 28 | args: { 29 | id: v.string(), 30 | status: v.string(), 31 | }, 32 | handler: async (ctx, args) => { 33 | const identity = await ctx.auth.getUserIdentity(); 34 | if (!identity) { 35 | throw new Error("Not logged in."); 36 | } 37 | const issue = await getIssue(ctx.db, args.id); 38 | return ctx.db.patch(issue._id, { 39 | status: args.status, 40 | modified: Date.now(), 41 | }); 42 | }, 43 | }); 44 | 45 | export const changePriority = mutation({ 46 | args: { 47 | id: v.string(), 48 | priority: v.string(), 49 | }, 50 | handler: async (ctx, args) => { 51 | const identity = await ctx.auth.getUserIdentity(); 52 | if (!identity) { 53 | throw new Error("Not logged in."); 54 | } 55 | const issue = await getIssue(ctx.db, args.id); 56 | return ctx.db.patch(issue._id, { 57 | priority: args.priority, 58 | modified: Date.now(), 59 | }); 60 | }, 61 | }); 62 | 63 | export const changeTitle = mutation({ 64 | args: { 65 | id: v.string(), 66 | title: v.string(), 67 | }, 68 | handler: async (ctx, args) => { 69 | const identity = await ctx.auth.getUserIdentity(); 70 | if (!identity) { 71 | throw new Error("Not logged in."); 72 | } 73 | const issue = await getIssue(ctx.db, args.id); 74 | return ctx.db.patch(issue._id, { title: args.title, modified: Date.now() }); 75 | }, 76 | }); 77 | 78 | export const changeDescription = mutation({ 79 | args: { 80 | id: v.string(), 81 | description: v.string(), 82 | }, 83 | handler: async (ctx, args) => { 84 | const identity = await ctx.auth.getUserIdentity(); 85 | if (!identity) { 86 | throw new Error("Not logged in."); 87 | } 88 | const issue = await getIssue(ctx.db, args.id); 89 | return ctx.db.patch(issue._id, { 90 | description: args.description, 91 | modified: Date.now(), 92 | }); 93 | }, 94 | }); 95 | 96 | export const deleteIssue = mutation({ 97 | args: { 98 | id: v.string(), 99 | }, 100 | handler: async (ctx, args) => { 101 | const identity = await ctx.auth.getUserIdentity(); 102 | if (!identity) { 103 | throw new Error("Not logged in."); 104 | } 105 | const issue = await getIssue(ctx.db, args.id); 106 | await ctx.db.delete(issue._id); 107 | }, 108 | }); 109 | 110 | async function maybeGetIssue(db: DatabaseReader, id: string) { 111 | return db 112 | .query("issues") 113 | .withIndex("by_issue_id", (q) => q.eq("id", id)) 114 | .unique(); 115 | } 116 | 117 | async function getIssue(db: DatabaseReader, id: string) { 118 | const issue = await maybeGetIssue(db, id); 119 | if (!issue) { 120 | throw new Error("Issue not found"); 121 | } 122 | return issue; 123 | } 124 | -------------------------------------------------------------------------------- /curvilinear/src/pages/Issue/Comments.tsx: -------------------------------------------------------------------------------- 1 | import { 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 "../../types/types"; 8 | import { useLocalStoreClient } from "local-store/react/LocalStoreProvider"; 9 | import { loadComments } from "@/local/queries"; 10 | import { useLocalQuery } from "local-store/react/hooks"; 11 | import { postComment } from "@/local/mutations"; 12 | import { useCachedUser } from "@/hooks/useUser"; 13 | 14 | export interface CommentsProps { 15 | issue: Issue; 16 | } 17 | 18 | function Comments(commentProps: CommentsProps) { 19 | const [newCommentBody, setNewCommentBody] = useState(``); 20 | const user = useCachedUser(); 21 | 22 | const comments: Comment[] | undefined = useLocalQuery(loadComments, { 23 | issue_id: commentProps.issue.id, 24 | }); 25 | 26 | const client = useLocalStoreClient(); 27 | 28 | const commentList = () => { 29 | if (comments && comments.length > 0) { 30 | return comments.map((comment) => ( 31 |
35 |
36 | 37 | 38 | {comment.username} 39 | 40 | 41 | {formatDate(new Date(comment.created_at))} 42 | 43 |
44 |
45 | {comment.body} 46 |
47 |
48 | )); 49 | } 50 | }; 51 | 52 | const handlePost = async (event: any) => { 53 | event.preventDefault(); 54 | if (!newCommentBody) { 55 | showWarning( 56 | `Please enter a comment before submitting`, 57 | `Comment required`, 58 | ); 59 | return; 60 | } 61 | if (!user || !user.fullName) { 62 | showWarning(`Please login to post a comment`, `Login required`); 63 | return; 64 | } 65 | const args = { 66 | id: crypto.randomUUID(), 67 | issue_id: commentProps.issue.id, 68 | body: newCommentBody, 69 | created_at: Date.now(), 70 | username: user.fullName, 71 | }; 72 | const promise = client.mutation(postComment, args); 73 | setNewCommentBody(""); 74 | await promise; 75 | }; 76 | 77 | return ( 78 | <> 79 | {commentList()} 80 |
81 |
82 | setNewCommentBody(val)} 86 | placeholder="Add a comment..." 87 | /> 88 |
89 |
90 | 97 |
98 |
99 | 100 | ); 101 | } 102 | 103 | export default Comments; 104 | -------------------------------------------------------------------------------- /local-store/browser/driver.ts: -------------------------------------------------------------------------------- 1 | import { CoreSyncEngine } from "./core/core"; 2 | import { 3 | CoreRequest, 4 | CoreResponse, 5 | UINewSyncQuery, 6 | UITransition, 7 | } from "./core/protocol"; 8 | import { LocalPersistence } from "./localPersistence"; 9 | import { Logger } from "./logger"; 10 | import { Network } from "./network"; 11 | 12 | export class Driver { 13 | coreLocalStore: CoreSyncEngine; 14 | network: Network; 15 | localPersistence: LocalPersistence; 16 | uiTransitionHandler: ((transition: UITransition) => void) | null = null; 17 | newSyncQueryResultHandler: ((result: UINewSyncQuery) => void) | null = null; 18 | requestQueue: CoreRequest[] = []; 19 | logger: Logger; 20 | constructor(opts: { 21 | coreLocalStore: CoreSyncEngine; 22 | network: Network; 23 | localPersistence: LocalPersistence; 24 | logger: Logger; 25 | }) { 26 | this.coreLocalStore = opts.coreLocalStore; 27 | this.network = opts.network; 28 | this.localPersistence = opts.localPersistence; 29 | this.logger = opts.logger; 30 | } 31 | 32 | step() { 33 | for (let i = 0; i < 1000; i++) { 34 | const requests = [...this.requestQueue]; 35 | this.requestQueue = []; 36 | for (const request of requests) { 37 | this.logger.debug("Driver.step: processing request", request); 38 | const responses = this.coreLocalStore.receive(request); 39 | for (const response of responses) { 40 | this.processResponse(response); 41 | } 42 | } 43 | if (this.requestQueue.length === 0) { 44 | return; 45 | } 46 | this.logger.warn( 47 | "Driver.step: Processing responses added more requests.", 48 | ); 49 | } 50 | throw new Error("Too many steps on a single Driver turn!"); 51 | } 52 | 53 | receive(message: CoreRequest) { 54 | this.requestQueue.push(message); 55 | this.step(); 56 | } 57 | 58 | addUiTransitionHandler(handler: (transition: UITransition) => void) { 59 | this.uiTransitionHandler = handler; 60 | } 61 | 62 | addNewSyncQueryResultHandler(handler: (result: UINewSyncQuery) => void) { 63 | this.newSyncQueryResultHandler = handler; 64 | } 65 | 66 | private processResponse(response: CoreResponse) { 67 | this.logger.debug("Driver.processResponse: processing", response); 68 | switch (response.kind) { 69 | case "newSyncQuery": { 70 | this.newSyncQueryResultHandler?.(response); 71 | return; 72 | } 73 | case "sendQueryToNetwork": { 74 | this.network.sendQueryToNetwork( 75 | response.syncFunction, 76 | response.pageRequest, 77 | ); 78 | return; 79 | } 80 | case "persistMutation": { 81 | this.localPersistence.persistMutation( 82 | response.persistId, 83 | response.mutationInfo, 84 | ); 85 | return; 86 | } 87 | case "persistPages": { 88 | this.localPersistence.persistPages(response.persistId, response.pages); 89 | return; 90 | } 91 | case "transition": { 92 | if (!this.uiTransitionHandler) { 93 | throw new Error("No UI transition handler"); 94 | } 95 | this.uiTransitionHandler(response); 96 | return; 97 | } 98 | case "removeQueryFromNetwork": 99 | this.network.removeQueryFromNetwork(response.queriesToRemove); 100 | return; 101 | case "sendMutationToNetwork": 102 | this.network.sendMutationToNetwork(response.mutationInfo); 103 | return; 104 | } 105 | const _typecheck: never = response; 106 | throw new Error("Unreachable"); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /curvilinear/src/components/ProfileMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Transition } from "@headlessui/react"; 2 | import { useRef, useState } from "react"; 3 | import classnames from "classnames"; 4 | import { useClickOutside } from "../hooks/useClickOutside"; 5 | import Toggle from "./Toggle"; 6 | import { SignOutButton } from "@clerk/clerk-react"; 7 | import Modal from "./Modal"; 8 | 9 | interface Props { 10 | isOpen: boolean; 11 | onDismiss?: () => void; 12 | setShowAboutModal?: (show: boolean) => void; 13 | setShowDebugView?: (show: boolean) => void; 14 | className?: string; 15 | } 16 | export default function ProfileMenu({ 17 | isOpen, 18 | className, 19 | onDismiss, 20 | setShowAboutModal, 21 | setShowDebugView, 22 | }: Props) { 23 | const connectivityState = { status: `disconnected` }; 24 | const classes = classnames( 25 | `select-none w-53 shadow-modal z-50 flex flex-col py-1 bg-white font-normal rounded text-gray-800`, 26 | className, 27 | ); 28 | const ref = useRef(null); 29 | 30 | const connectivityConnected = connectivityState.status !== `disconnected`; 31 | const connectivityStateDisplay = 32 | connectivityState.status[0].toUpperCase() + 33 | connectivityState.status.slice(1); 34 | 35 | useClickOutside(ref, () => { 36 | if (isOpen && onDismiss) { 37 | onDismiss(); 38 | } 39 | }); 40 | 41 | return ( 42 |
43 | 54 | 63 | 67 | Visit Convex 68 | 69 | 73 | Documentation 74 | 75 | 79 | GitHub 80 | 81 | 90 | 91 | 92 | 93 | 94 | {/* XXX: Connectivity tester */} 95 | {/*
96 | 97 | {connectivityStateDisplay} 98 | 99 | 105 |
*/} 106 |
107 |
108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /local-store/browser/core/optimisticUpdateExecutor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DefaultFunctionArgs, 3 | FunctionReference, 4 | SchemaDefinition, 5 | } from "convex/server"; 6 | import { IndexRangeRequest, MutationInfo, TableName } from "../../shared/types"; 7 | import { Writes } from "./protocol"; 8 | import { LoadingError } from "../localDbReader"; 9 | import { GenericId, Value } from "convex/values"; 10 | import { LocalDbWriterImpl } from "../localDbWriter"; 11 | import { CopyOnWriteLocalStore } from "./localStore"; 12 | import { SingleIndexRangeExecutor } from "./paginator"; 13 | import { LocalDbWriter } from "../../react/localDb"; 14 | 15 | export type SyncMutation = (ctx: { localDb: any }, args: any) => Value; 16 | 17 | export type MutationMap = Record< 18 | string, 19 | { 20 | fn: FunctionReference<"mutation">; 21 | optimisticUpdate?: ( 22 | ctx: { localDb: LocalDbWriter }, 23 | args: DefaultFunctionArgs, 24 | ) => void; 25 | } 26 | >; 27 | 28 | export function executeSyncMutation( 29 | syncSchema: SchemaDefinition, 30 | mutationMap: MutationMap, 31 | mutationInfo: MutationInfo, 32 | localStore: CopyOnWriteLocalStore, 33 | ): { 34 | result: "loading" | "success" | "error"; 35 | localStore: CopyOnWriteLocalStore; 36 | } { 37 | let syncMutationResult: "loading" | "success" | "error" | null = null; 38 | const syncMutation = mutationMap[mutationInfo.mutationName]; 39 | if (syncMutation === undefined) { 40 | console.error("Sync mutation not found"); 41 | return { 42 | result: "error", 43 | localStore, 44 | }; 45 | } 46 | const optimisticUpdate = syncMutation.optimisticUpdate; 47 | if (optimisticUpdate === undefined) { 48 | console.warn("Optimistic update for sync mutation not found"); 49 | return { 50 | result: "success", 51 | localStore, 52 | }; 53 | } 54 | 55 | const handleRangeRequest = (rangeRequest: IndexRangeRequest) => { 56 | const singleIndexRangeExecutor = new SingleIndexRangeExecutor( 57 | rangeRequest, 58 | syncSchema, 59 | localStoreClone, 60 | ); 61 | const result = singleIndexRangeExecutor.tryFulfill(); 62 | switch (result.state) { 63 | case "fulfilled": 64 | return result.results; 65 | case "waitingOnLoadingPage": { 66 | syncMutationResult = "loading"; 67 | throw new LoadingError(); 68 | } 69 | case "needsMorePages": { 70 | syncMutationResult = "loading"; 71 | throw new LoadingError(); 72 | } 73 | } 74 | }; 75 | 76 | const loadObject = (tableName: TableName, id: GenericId) => { 77 | const result = localStore.loadObject(tableName, id); 78 | if (result === undefined) { 79 | syncMutationResult = "loading"; 80 | throw new LoadingError(); 81 | } 82 | return result; 83 | }; 84 | 85 | const localStoreClone = localStore.clone(); 86 | const writes = new Writes(); 87 | const localDb = new LocalDbWriterImpl( 88 | syncSchema, 89 | handleRangeRequest, 90 | loadObject, 91 | (tableName, id, doc) => { 92 | writes.set(tableName, id, doc); 93 | localStoreClone.applyWrites(writes); 94 | }, 95 | ); 96 | try { 97 | optimisticUpdate({ localDb: localDb as any }, mutationInfo.optUpdateArgs); 98 | if (syncMutationResult === null) { 99 | syncMutationResult = "success"; 100 | } 101 | } catch (e) { 102 | if (e instanceof LoadingError) { 103 | syncMutationResult = "loading"; 104 | } else { 105 | syncMutationResult = "error"; 106 | } 107 | } 108 | return { 109 | result: syncMutationResult, 110 | localStore: syncMutationResult === "success" ? localStoreClone : localStore, 111 | }; 112 | } 113 | -------------------------------------------------------------------------------- /curvilinear/convex/_generated/server.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { 12 | actionGeneric, 13 | httpActionGeneric, 14 | queryGeneric, 15 | mutationGeneric, 16 | internalActionGeneric, 17 | internalMutationGeneric, 18 | internalQueryGeneric, 19 | } from "convex/server"; 20 | 21 | /** 22 | * Define a query in this Convex app's public API. 23 | * 24 | * This function will be allowed to read your Convex database and will be accessible from the client. 25 | * 26 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 27 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 28 | */ 29 | export const query = queryGeneric; 30 | 31 | /** 32 | * Define a query that is only accessible from other Convex functions (but not from the client). 33 | * 34 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 35 | * 36 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 37 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 38 | */ 39 | export const internalQuery = internalQueryGeneric; 40 | 41 | /** 42 | * Define a mutation in this Convex app's public API. 43 | * 44 | * This function will be allowed to modify your Convex database and will be accessible from the client. 45 | * 46 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 47 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 48 | */ 49 | export const mutation = mutationGeneric; 50 | 51 | /** 52 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 53 | * 54 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 55 | * 56 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 57 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 58 | */ 59 | export const internalMutation = internalMutationGeneric; 60 | 61 | /** 62 | * Define an action in this Convex app's public API. 63 | * 64 | * An action is a function which can execute any JavaScript code, including non-deterministic 65 | * code and code with side-effects, like calling third-party services. 66 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 67 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 68 | * 69 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 70 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 71 | */ 72 | export const action = actionGeneric; 73 | 74 | /** 75 | * Define an action that is only accessible from other Convex functions (but not from the client). 76 | * 77 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 78 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 79 | */ 80 | export const internalAction = internalActionGeneric; 81 | 82 | /** 83 | * Define a Convex HTTP action. 84 | * 85 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object 86 | * as its second. 87 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. 88 | */ 89 | export const httpAction = httpActionGeneric; 90 | -------------------------------------------------------------------------------- /local-store/react/LocalStoreProvider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, ReactNode, useContext } from "react"; 2 | import { LocalStoreClient } from "../browser/ui"; 3 | import { SchemaDefinition } from "convex/server"; 4 | import { BaseConvexClient } from "convex/browser"; 5 | import { Driver } from "../browser/driver"; 6 | import { Logger } from "../browser/logger"; 7 | import { CoreSyncEngine } from "../browser/core/core"; 8 | import { NetworkImpl } from "../browser/network"; 9 | import { 10 | LocalPersistence, 11 | NoopLocalPersistence, 12 | } from "../browser/localPersistence"; 13 | import { MutationRegistry } from "./mutationRegistry"; 14 | import { ConvexReactClient } from "convex/react"; 15 | import { Election } from "../browser/worker/election"; 16 | export const LocalStoreContext = createContext(null); 17 | 18 | type LocalStoreProviderProps> = 19 | | { 20 | children: ReactNode; 21 | 22 | client: BaseConvexClient; 23 | syncSchema: SyncSchema; 24 | persistence?: LocalPersistence; 25 | mutations: MutationRegistry; 26 | } 27 | | { 28 | children: ReactNode; 29 | 30 | localStoreClient: LocalStoreClient; 31 | }; 32 | 33 | export function LocalStoreProvider< 34 | SyncSchema extends SchemaDefinition, 35 | >(props: LocalStoreProviderProps) { 36 | let localStoreClient: LocalStoreClient; 37 | if ("localStoreClient" in props) { 38 | localStoreClient = props.localStoreClient; 39 | } else { 40 | const { client, syncSchema, mutations, persistence } = props; 41 | const logger = new Logger(); 42 | const mutationMap = mutations.exportToMutationMap(); 43 | const coreLocalStore = new CoreSyncEngine(syncSchema, mutationMap, logger); 44 | const driver = new Driver({ 45 | coreLocalStore, 46 | network: new NetworkImpl({ convexClient: client }), 47 | localPersistence: persistence ?? new NoopLocalPersistence(), 48 | logger, 49 | }); 50 | localStoreClient = new LocalStoreClient({ 51 | driver, 52 | syncSchema, 53 | mutations: mutationMap, 54 | }); 55 | } 56 | (globalThis as any).localDb = localStoreClient; 57 | return ( 58 | 59 | {props.children} 60 | 61 | ); 62 | } 63 | 64 | export function useLocalStoreClient(): LocalStoreClient { 65 | const localStoreClient = useContext(LocalStoreContext); 66 | if (localStoreClient === null) { 67 | throw new Error( 68 | "useLocalStoreClient must be used within a LocalStoreProvider", 69 | ); 70 | } 71 | return localStoreClient; 72 | } 73 | 74 | export function createLocalStoreClient(opts: { 75 | syncSchema: SchemaDefinition; 76 | mutationRegistry: MutationRegistry; 77 | convexClient: ConvexReactClient; 78 | convexUrl: string; 79 | persistenceKey: string | null; 80 | }) { 81 | const persistence = opts.persistenceKey 82 | ? new Election(opts.persistenceKey, opts.convexUrl) 83 | : new NoopLocalPersistence(); 84 | const logger = new Logger(); 85 | const mutationMap = opts.mutationRegistry.exportToMutationMap(); 86 | const coreLocalStore = new CoreSyncEngine( 87 | opts.syncSchema, 88 | mutationMap, 89 | logger, 90 | ); 91 | const driver = new Driver({ 92 | coreLocalStore, 93 | network: new NetworkImpl({ convexClient: opts.convexClient.sync }), 94 | localPersistence: persistence ?? new NoopLocalPersistence(), 95 | logger, 96 | }); 97 | const localStore = new LocalStoreClient({ 98 | driver, 99 | syncSchema: opts.syncSchema, 100 | mutations: mutationMap, 101 | }); 102 | return localStore; 103 | } 104 | -------------------------------------------------------------------------------- /curvilinear/src/components/contextmenu/FilterMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Portal } from "../Portal"; 2 | import { ReactNode, useState } from "react"; 3 | import { ContextMenuTrigger } from "@firefox-devtools/react-contextmenu"; 4 | import { BsCheck2 } from "react-icons/bs"; 5 | import { Menu } from "./menu"; 6 | import { useFilterState } from "../../utils/filterState"; 7 | import { PriorityOptions, StatusOptions } from "../../types/types"; 8 | 9 | interface Props { 10 | id: string; 11 | button: ReactNode; 12 | className?: string; 13 | } 14 | 15 | function FilterMenu({ id, button, className }: Props) { 16 | const [filterState, setFilterState] = useFilterState(); 17 | const [keyword, setKeyword] = useState(``); 18 | 19 | let priorities = PriorityOptions; 20 | if (keyword !== ``) { 21 | const normalizedKeyword = keyword.toLowerCase().trim(); 22 | priorities = priorities.filter( 23 | ([_icon, _priority, label]) => 24 | (label as string).toLowerCase().indexOf(normalizedKeyword) !== -1, 25 | ); 26 | } 27 | 28 | let statuses = StatusOptions; 29 | if (keyword !== ``) { 30 | const normalizedKeyword = keyword.toLowerCase().trim(); 31 | statuses = statuses.filter( 32 | ([_icon, _status, label]) => 33 | label.toLowerCase().indexOf(normalizedKeyword) !== -1, 34 | ); 35 | } 36 | 37 | const priorityOptions = priorities.map(([svg, priority, label], idx) => { 38 | return ( 39 | handlePrioritySelect(priority as string)} 42 | > 43 | {priority} 44 | {label} 45 | {filterState.priority?.includes(priority) && ( 46 | 47 | )} 48 | 49 | ); 50 | }); 51 | 52 | const statusOptions = statuses.map(([svg, status, label], idx) => { 53 | return ( 54 | handleStatusSelect(status as string)} 57 | > 58 | {status} 59 | {label} 60 | {filterState.status?.includes(status) && ( 61 | 62 | )} 63 | 64 | ); 65 | }); 66 | 67 | const handlePrioritySelect = (priority: string) => { 68 | setKeyword(``); 69 | const newPriority = filterState.priority || []; 70 | if (newPriority.includes(priority)) { 71 | newPriority.splice(newPriority.indexOf(priority), 1); 72 | } else { 73 | newPriority.push(priority); 74 | } 75 | setFilterState({ 76 | ...filterState, 77 | priority: newPriority, 78 | }); 79 | }; 80 | 81 | const handleStatusSelect = (status: string) => { 82 | setKeyword(``); 83 | const newStatus = filterState.status || []; 84 | if (newStatus.includes(status)) { 85 | newStatus.splice(newStatus.indexOf(status), 1); 86 | } else { 87 | newStatus.push(status); 88 | } 89 | setFilterState({ 90 | ...filterState, 91 | status: newStatus, 92 | }); 93 | }; 94 | 95 | return ( 96 | <> 97 | 98 | {button} 99 | 100 | 101 | 102 | setKeyword(kw)} 109 | > 110 | {priorityOptions && Priority} 111 | {priorityOptions} 112 | {priorityOptions && statusOptions && } 113 | {statusOptions && Status} 114 | {statusOptions} 115 | 116 | 117 | 118 | ); 119 | } 120 | 121 | export default FilterMenu; 122 | -------------------------------------------------------------------------------- /curvilinear/src/components/editor/EditorMenu.tsx: -------------------------------------------------------------------------------- 1 | import type { Editor as TipTapEditor } from "@tiptap/react"; 2 | import classNames from "classnames"; 3 | 4 | import { BsTypeBold as BoldIcon } from "react-icons/bs"; 5 | import { BsTypeItalic as ItalicIcon } from "react-icons/bs"; 6 | import { BsTypeStrikethrough as StrikeIcon } from "react-icons/bs"; 7 | import { BsCode as CodeIcon } from "react-icons/bs"; 8 | import { BsListUl as BulletListIcon } from "react-icons/bs"; 9 | import { BsListOl as OrderedListIcon } from "react-icons/bs"; 10 | import { BsCodeSlash as CodeBlockIcon } from "react-icons/bs"; 11 | import { BsChatQuote as BlockquoteIcon } from "react-icons/bs"; 12 | 13 | export interface EditorMenuProps { 14 | editor: TipTapEditor; 15 | } 16 | 17 | const EditorMenu = ({ editor }: EditorMenuProps) => { 18 | return ( 19 |
20 | 32 | 44 | 56 | 68 |
69 | 80 | 91 | 102 | 113 |
114 | ); 115 | }; 116 | 117 | export default EditorMenu; 118 | -------------------------------------------------------------------------------- /local-store/browser/localDbReader.ts: -------------------------------------------------------------------------------- 1 | import { GenericDocument, IndexRange } from "convex/server"; 2 | import { DocumentByInfo, NamedIndex } from "convex/server"; 3 | import { GenericTableInfo } from "convex/server"; 4 | import { IndexRangeBuilder } from "convex/server"; 5 | import { IndexName, IndexPrefix, IndexRangeRequest } from "../shared/types"; 6 | 7 | import { IndexRangeBounds, TableName } from "../shared/types"; 8 | import { PaginatorIndexRange } from "../shared/pagination"; 9 | import { GenericId } from "convex/values"; 10 | import { indexFieldsForSyncObject } from "../server/resolvers"; 11 | 12 | export class LocalDbReaderImpl { 13 | public debugIndexRanges: Map< 14 | string, 15 | { 16 | table: string; 17 | index: string; 18 | indexRangeBounds: IndexRangeBounds; 19 | order: "asc" | "desc"; 20 | limit: number; 21 | } 22 | > = new Map(); 23 | constructor( 24 | private syncSchema: any, 25 | private requestRange: ( 26 | rangeRequest: IndexRangeRequest, 27 | ) => Array, 28 | private loadObject: ( 29 | table: TableName, 30 | id: GenericId, 31 | ) => GenericDocument | null, 32 | ) {} 33 | 34 | query(table: TableName) { 35 | return { 36 | fullTableScan: () => { 37 | throw new Error("Not implemented"); 38 | }, 39 | withIndex: ( 40 | indexName: IndexName, 41 | indexBuilder?: ( 42 | q: IndexRangeBuilder< 43 | DocumentByInfo, 44 | NamedIndex 45 | >, 46 | ) => IndexRange, 47 | ) => { 48 | return { 49 | collect: () => this.queryRange(table, indexName, indexBuilder, "asc"), 50 | take: (count: number) => 51 | this.queryRange(table, indexName, indexBuilder, "asc", count), 52 | order: (order: "asc" | "desc") => { 53 | return { 54 | collect: () => 55 | this.queryRange( 56 | table, 57 | indexName, 58 | indexBuilder, 59 | order, 60 | Number.POSITIVE_INFINITY, 61 | ), 62 | take: (count: number) => 63 | this.queryRange(table, indexName, indexBuilder, order, count), 64 | }; 65 | }, 66 | }; 67 | }, 68 | withSearchIndex: (_indexName: any, _indexBuilder: any) => { 69 | throw new Error("Not implemented"); 70 | }, 71 | }; 72 | } 73 | 74 | get(table: TableName, id: GenericId) { 75 | return this.loadObject(table, id); 76 | } 77 | 78 | private queryRange( 79 | table: TableName, 80 | index: IndexName, 81 | indexRange?: ( 82 | q: IndexRangeBuilder< 83 | DocumentByInfo, 84 | NamedIndex 85 | >, 86 | ) => IndexRange, 87 | order: "asc" | "desc" = "asc", 88 | count: number = Number.POSITIVE_INFINITY, 89 | ): GenericDocument[] { 90 | console.log("queryRange", table, index, order, count); 91 | // TODO: should do something like this 92 | // or even better, use the DatabaseImpl wholesale from convex/server 93 | // const indexRangeJson = (indexRange(IndexRangeBuilderImpl.new()) as any).export() 94 | // But that's not exported from convex/server yet. 95 | // So we do the hack using the version in convex-helpers. 96 | const indexFields = indexFieldsForSyncObject(this.syncSchema, table, index); 97 | const paginatorIndexRange = new PaginatorIndexRange(indexFields); 98 | if (indexRange) { 99 | indexRange(paginatorIndexRange as any); 100 | } 101 | const indexRangeBounds: IndexRangeBounds = { 102 | lowerBound: 103 | paginatorIndexRange.lowerBoundIndexKey ?? 104 | ([] as unknown as IndexPrefix), 105 | lowerBoundInclusive: paginatorIndexRange.lowerBoundInclusive, 106 | upperBound: 107 | paginatorIndexRange.upperBoundIndexKey ?? 108 | ([] as unknown as IndexPrefix), 109 | upperBoundInclusive: paginatorIndexRange.upperBoundInclusive, 110 | }; 111 | // requestRange will cancel the query execution if the result is not loaded, 112 | // and make the sync query return loading 113 | return this.requestRange({ 114 | tableName: table, 115 | indexName: index, 116 | count, 117 | indexRangeBounds, 118 | order, 119 | }); 120 | } 121 | } 122 | 123 | export class LoadingError extends Error { 124 | constructor() { 125 | super("Index range is loading in sync query"); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /local-store/browser/core/syncQueryExecutor.ts: -------------------------------------------------------------------------------- 1 | import { anyApi } from "convex/server"; 2 | import { 3 | IndexRangeRequest, 4 | SyncQueryResult, 5 | ConvexSubscriptionId, 6 | TableName, 7 | IndexName, 8 | PageArguments, 9 | } from "../../shared/types"; 10 | import { CoreResponse } from "./protocol"; 11 | import { LoadingError, LocalDbReaderImpl } from "../localDbReader"; 12 | import { GenericId, Value } from "convex/values"; 13 | import { CopyOnWriteLocalStore } from "./localStore"; 14 | import { LOG2_PAGE_SIZE, SingleIndexRangeExecutor } from "./paginator"; 15 | import { createQueryToken } from "../../shared/queryTokens"; 16 | 17 | export type SyncQuery = (ctx: { localDb: any }, args: any) => Value; 18 | 19 | export type SyncQueryExecutionResult = { 20 | responses: CoreResponse[]; 21 | pagesRead: ConvexSubscriptionId[]; 22 | newPage: { 23 | subscriptionId: ConvexSubscriptionId; 24 | args: PageArguments; 25 | } | null; 26 | result: SyncQueryResult; 27 | }; 28 | 29 | function getSyncFunction(tableName: TableName, indexName: IndexName) { 30 | return anyApi.sync[tableName][indexName]; 31 | } 32 | 33 | export function executeSyncQuery( 34 | syncSchema: any, 35 | syncQueryFn: SyncQuery, 36 | syncQueryArgs: any, 37 | localStore: CopyOnWriteLocalStore, 38 | ): SyncQueryExecutionResult { 39 | let syncQueryResult: SyncQueryResult | undefined; 40 | let newPage: { 41 | subscriptionId: ConvexSubscriptionId; 42 | args: PageArguments; 43 | } | null = null; 44 | const responses: CoreResponse[] = []; 45 | const pagesRead: Set = new Set(); 46 | 47 | const handleRangeRequest = (rangeRequest: IndexRangeRequest) => { 48 | const singleIndexRangeExecutor = new SingleIndexRangeExecutor( 49 | rangeRequest, 50 | syncSchema, 51 | localStore, 52 | ); 53 | 54 | const result = singleIndexRangeExecutor.tryFulfill(); 55 | switch (result.state) { 56 | case "fulfilled": 57 | for (const pageSubscriptionId of result.pageSubscriptionIds) { 58 | pagesRead.add(pageSubscriptionId); 59 | } 60 | return result.results; 61 | case "waitingOnLoadingPage": { 62 | for (const pageSubscriptionId of result.loadingPageSubscriptionIds) { 63 | pagesRead.add(pageSubscriptionId); 64 | } 65 | syncQueryResult = { kind: "loading" }; 66 | throw new LoadingError(); 67 | } 68 | case "needsMorePages": { 69 | result.existingPageSubscriptionIds.forEach((id) => { 70 | pagesRead.add(id); 71 | }); 72 | const pageArgs = { 73 | syncTableName: rangeRequest.tableName, 74 | index: rangeRequest.indexName, 75 | target: result.targetKey, 76 | log2PageSize: LOG2_PAGE_SIZE, 77 | }; 78 | const pageSubscriptionId = createQueryToken( 79 | getSyncFunction(rangeRequest.tableName, rangeRequest.indexName), 80 | pageArgs, 81 | ); 82 | newPage = { subscriptionId: pageSubscriptionId, args: pageArgs }; 83 | pagesRead.add(pageSubscriptionId); 84 | responses.push({ 85 | recipient: "Network", 86 | kind: "sendQueryToNetwork", 87 | syncFunction: getSyncFunction( 88 | rangeRequest.tableName, 89 | rangeRequest.indexName, 90 | ), 91 | pageRequest: pageArgs, 92 | }); 93 | syncQueryResult = { kind: "loading" }; 94 | throw new LoadingError(); 95 | } 96 | } 97 | }; 98 | 99 | const loadObject = (tableName: TableName, id: GenericId) => { 100 | const result = localStore.loadObject(tableName, id); 101 | if (result === undefined) { 102 | syncQueryResult = { kind: "loading" }; 103 | throw new LoadingError(); 104 | } 105 | return result; 106 | }; 107 | 108 | const localDb = new LocalDbReaderImpl( 109 | syncSchema, 110 | handleRangeRequest, 111 | loadObject, 112 | ); 113 | try { 114 | const result = syncQueryFn({ localDb }, syncQueryArgs); 115 | if (result instanceof Promise) { 116 | syncQueryResult = { 117 | kind: "loaded", 118 | status: "error", 119 | error: new Error("Sync query returned a promise"), 120 | }; 121 | } 122 | if (syncQueryResult === undefined) { 123 | syncQueryResult = { kind: "loaded", status: "success", value: result }; 124 | } 125 | } catch (e) { 126 | if (e instanceof LoadingError) { 127 | syncQueryResult = { kind: "loading" }; 128 | } else if (syncQueryResult === undefined) { 129 | syncQueryResult = { 130 | kind: "loaded", 131 | status: "error", 132 | error: e, 133 | }; 134 | } 135 | } 136 | return { 137 | responses, 138 | pagesRead: Array.from(pagesRead), 139 | newPage, 140 | result: syncQueryResult, 141 | }; 142 | } 143 | -------------------------------------------------------------------------------- /local-store/shared/types.ts: -------------------------------------------------------------------------------- 1 | import { QueryToken } from "convex/browser"; 2 | import { 3 | DefaultFunctionArgs, 4 | FunctionReference, 5 | GenericDocument, 6 | } from "convex/server"; 7 | import { Value } from "convex/values"; 8 | import { assert } from "./assert"; 9 | 10 | export type ConvexSubscriptionId = QueryToken & { 11 | __brand: "ConvexSubscriptionId"; 12 | }; 13 | 14 | export type SyncQuerySubscriptionId = string & { 15 | __brand: "SyncQuerySubscriptionId"; 16 | }; 17 | 18 | export type SyncQueryResult = 19 | | { 20 | kind: "loaded"; 21 | status: "success"; 22 | value: Value; 23 | } 24 | | { 25 | kind: "loaded"; 26 | status: "error"; 27 | error: any; 28 | } 29 | | { kind: "loading" }; 30 | 31 | export type SyncQueryExecutionId = number & { __brand: "SyncQueryExecutionId" }; 32 | 33 | export type PersistId = string & { __brand: "PersistId" }; 34 | 35 | export type MutationResult = 36 | | { 37 | status: "success"; 38 | value: Value; 39 | } 40 | | { 41 | status: "error"; 42 | error: any; 43 | }; 44 | 45 | export type MutationInfo = { 46 | mutationName: string; 47 | mutationId: MutationId; 48 | mutationPath: FunctionReference<"mutation">; 49 | optUpdateArgs: DefaultFunctionArgs; 50 | serverArgs: DefaultFunctionArgs; 51 | }; 52 | 53 | export type ServerStoreVersion = number & { __brand: "ServerStoreVersion" }; 54 | 55 | export class LocalStoreVersion { 56 | private version: number; 57 | serverVersion: ServerStoreVersion; 58 | constructor(version: number, serverVersion: ServerStoreVersion) { 59 | this.version = version; 60 | this.serverVersion = serverVersion; 61 | } 62 | 63 | increment(): LocalStoreVersion { 64 | return new LocalStoreVersion(this.version + 1, this.serverVersion); 65 | } 66 | 67 | advanceToServerVersion(serverVersion: ServerStoreVersion): LocalStoreVersion { 68 | assert( 69 | serverVersion > this.serverVersion, 70 | "Cannot advance to a past server version", 71 | ); 72 | return new LocalStoreVersion(this.version + 1, serverVersion); 73 | } 74 | } 75 | 76 | export type MutationId = string & { __brand: "MutationId" }; 77 | 78 | export type TableName = string; 79 | export type IndexName = string; 80 | 81 | // has all of the fields of the index 82 | export type IndexKey = ReadonlyArray; 83 | 84 | export type IndexPrefix = ReadonlyArray; 85 | 86 | export const MAXIMAL_KEY = { 87 | kind: "successor", 88 | value: [], 89 | } as const; 90 | 91 | export const MINIMAL_KEY = { 92 | kind: "predecessor", 93 | value: [], 94 | } as const; 95 | 96 | export type ExactKey = { 97 | kind: "exact"; 98 | value: IndexKey; 99 | }; 100 | 101 | export type Key = 102 | | { 103 | kind: "successor" | "predecessor"; 104 | value: IndexPrefix; 105 | } 106 | | ExactKey; 107 | 108 | export type PageArguments = { 109 | syncTableName: string; 110 | index: string; 111 | target: Key; 112 | log2PageSize: number; 113 | }; 114 | 115 | export type PageResult = { 116 | results: GenericDocument[]; 117 | lowerBound: LowerBound; 118 | upperBound: UpperBound; 119 | }; 120 | 121 | export type LowerBound = 122 | | { kind: "successor"; value: IndexPrefix } 123 | | typeof MINIMAL_KEY; 124 | export type UpperBound = ExactKey | typeof MAXIMAL_KEY; 125 | 126 | export type IndexRangeBounds = { 127 | lowerBound: IndexPrefix; // [conversationId] 128 | lowerBoundInclusive: boolean; 129 | upperBound: IndexPrefix; // [conversationId] 130 | upperBoundInclusive: boolean; 131 | }; 132 | 133 | // Output cursor from the developer-defined generator function. 134 | export type GeneratorCursor = 135 | | typeof MAXIMAL_KEY 136 | | typeof MINIMAL_KEY 137 | | ExactKey; 138 | // Input cursor to the developer-defined generator function. 139 | // In addition to being an index key or minimal/maximal key, it can also be an inclusive or exclusive bound. 140 | export type GeneratorInputCursor = { 141 | key: IndexPrefix; 142 | inclusive: boolean; 143 | }; 144 | 145 | export const isMaximal = (c: Key): c is typeof MAXIMAL_KEY => { 146 | return c.kind === "successor" && c.value.length === 0; 147 | }; 148 | 149 | export const isMinimal = (c: Key): c is typeof MINIMAL_KEY => { 150 | return c.kind === "predecessor" && c.value.length === 0; 151 | }; 152 | 153 | export const isExact = (c: Key): c is ExactKey => { 154 | return c.kind === "exact"; 155 | }; 156 | 157 | // Corresponds to a `db.query` 158 | export type PaginatorSubscriptionId = string & { 159 | __brand: "PaginatorSubscriptionId"; 160 | }; 161 | 162 | export type SyncFunction = FunctionReference< 163 | "query", 164 | "public", 165 | PageArguments, 166 | PageResult 167 | >; 168 | 169 | export type SyncGetFunction = FunctionReference< 170 | "query", 171 | "public", 172 | { _id: string }, 173 | GenericDocument | null 174 | >; 175 | 176 | export type IndexRangeRequest = { 177 | tableName: TableName; 178 | indexName: IndexName; 179 | count: number; 180 | indexRangeBounds: IndexRangeBounds; 181 | order: "asc" | "desc"; 182 | }; 183 | -------------------------------------------------------------------------------- /local-store/browser/core/protocol.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConvexSubscriptionId, 3 | IndexName, 4 | Key, 5 | MutationInfo, 6 | PageArguments, 7 | PageResult, 8 | PersistId, 9 | TableName, 10 | } from "../../shared/types"; 11 | 12 | import { GenericId, convexToJson } from "convex/values"; 13 | import { 14 | MutationId, 15 | SyncFunction, 16 | SyncQueryResult, 17 | SyncQuerySubscriptionId, 18 | } from "../../shared/types"; 19 | import { DefaultFunctionArgs, GenericDocument } from "convex/server"; 20 | import { SyncQuery } from "./syncQueryExecutor"; 21 | 22 | export type RangeResponse = Array; 23 | 24 | export type Page = { 25 | tableName: TableName; 26 | indexName: IndexName; 27 | convexSubscriptionId: ConvexSubscriptionId; 28 | state: 29 | | { 30 | kind: "loaded"; 31 | value: PageResult; 32 | } 33 | | { 34 | kind: "loading"; 35 | target: Key; 36 | }; 37 | }; 38 | 39 | export class Writes { 40 | writes: Map, GenericDocument | null>>; 41 | constructor() { 42 | this.writes = new Map(); 43 | } 44 | 45 | prettyPrint(): string { 46 | let result = ""; 47 | for (const [tableName, tableWrites] of this.writes.entries()) { 48 | result += `${tableName}:\n`; 49 | for (const [id, doc] of tableWrites.entries()) { 50 | result += ` ${id}: ${JSON.stringify(convexToJson(doc))}\n`; 51 | } 52 | } 53 | return result; 54 | } 55 | 56 | set(tableName: TableName, id: GenericId, doc: GenericDocument | null) { 57 | if (!this.writes.has(tableName)) { 58 | this.writes.set(tableName, new Map()); 59 | } 60 | const tableWrites = this.writes.get(tableName)!; 61 | tableWrites.set(id, doc); 62 | } 63 | 64 | apply(other: Writes) { 65 | for (const [tableName, tableWrites] of other.writes) { 66 | const existingTableWrites = this.writes.get(tableName) ?? new Map(); 67 | for (const [id, write] of tableWrites) { 68 | existingTableWrites.set(id, write); 69 | } 70 | this.writes.set(tableName, existingTableWrites); 71 | } 72 | } 73 | 74 | clone(): Writes { 75 | const clone = new Writes(); 76 | clone.writes = new Map( 77 | Array.from(this.writes.entries()).map(([tableName, tableWrites]) => [ 78 | tableName, 79 | new Map(tableWrites.entries()), 80 | ]), 81 | ); 82 | return clone; 83 | } 84 | } 85 | 86 | export type CorePersistenceRequest = 87 | | { 88 | requestor: "LocalPersistence"; 89 | kind: "ingestFromLocalPersistence"; 90 | pages: Page[]; 91 | serverTs: number; 92 | } 93 | | { 94 | requestor: "LocalPersistence"; 95 | kind: "localPersistComplete"; 96 | persistId: PersistId; 97 | }; 98 | 99 | export type CoreRequest = 100 | | { 101 | requestor: "UI"; 102 | kind: "addSyncQuerySubscription"; 103 | syncQuerySubscriptionId: SyncQuerySubscriptionId; 104 | syncQueryFn: SyncQuery; 105 | syncQueryArgs: DefaultFunctionArgs; 106 | } 107 | | { 108 | requestor: "UI"; 109 | kind: "unsubscribeFromSyncQuery"; 110 | syncQuerySubscriptionId: SyncQuerySubscriptionId; 111 | } 112 | | { 113 | requestor: "UI"; 114 | kind: "mutate"; 115 | mutationInfo: MutationInfo; 116 | } 117 | | NetworkTransition 118 | | { 119 | requestor: "Network"; 120 | kind: "mutationResponseFromNetwork"; 121 | mutationId: string; 122 | result: any; 123 | } 124 | | CorePersistenceRequest; 125 | 126 | export type NetworkTransition = { 127 | requestor: "Network"; 128 | kind: "transitionFromNetwork"; 129 | serverTs: number; 130 | queryResults: Map< 131 | ConvexSubscriptionId, 132 | | { kind: "success"; result: Page } 133 | | { kind: "error"; errorMessage: string; errorData: any } 134 | >; 135 | reflectedMutations: MutationId[]; 136 | }; 137 | 138 | export type UITransition = { 139 | recipient: "UI"; 140 | kind: "transition"; 141 | syncQueryUpdates: Map; 142 | mutationsApplied: Set; 143 | }; 144 | 145 | export type UINewSyncQuery = { 146 | recipient: "UI"; 147 | kind: "newSyncQuery"; 148 | syncQuerySubscriptionId: SyncQuerySubscriptionId; 149 | syncQueryResult: SyncQueryResult; 150 | }; 151 | 152 | export type CoreResponse = 153 | | UITransition 154 | | UINewSyncQuery 155 | | { 156 | recipient: "Network"; 157 | kind: "sendMutationToNetwork"; 158 | mutationInfo: MutationInfo; 159 | } 160 | | { 161 | recipient: "Network"; 162 | kind: "sendQueryToNetwork"; 163 | syncFunction: SyncFunction; 164 | pageRequest: PageArguments; 165 | } 166 | | { 167 | recipient: "Network"; 168 | kind: "removeQueryFromNetwork"; 169 | queriesToRemove: ConvexSubscriptionId[]; 170 | } 171 | | { 172 | recipient: "LocalPersistence"; 173 | kind: "persistPages"; 174 | pages: Page[]; 175 | persistId: PersistId; 176 | } 177 | | { 178 | recipient: "LocalPersistence"; 179 | kind: "persistMutation"; 180 | persistId: PersistId; 181 | mutationInfo: MutationInfo; 182 | }; 183 | -------------------------------------------------------------------------------- /local-store/browser/worker/types.ts: -------------------------------------------------------------------------------- 1 | import { convexToJson, jsonToConvex } from "convex/values"; 2 | import { z } from "zod"; 3 | import { 4 | ConvexSubscriptionId, 5 | LowerBound, 6 | UpperBound, 7 | MutationInfo, 8 | MutationId, 9 | } from "../../shared/types"; 10 | import { Page } from "../core/protocol"; 11 | import { getFunctionName, makeFunctionReference } from "convex/server"; 12 | 13 | // Each instance of the `SyncWorkerClient` class receives a unique instance ID. 14 | export type ClientId = string; 15 | 16 | const storedIndexPrefix = z.array(z.any()); 17 | const storedMinimalKey = z.object({ 18 | kind: z.literal("predecessor"), 19 | value: z.array(z.never()), 20 | }); 21 | const storedLowerBound = z.union([ 22 | z.object({ kind: z.literal("successor"), value: storedIndexPrefix }), 23 | storedMinimalKey, 24 | ]); 25 | const storedIndexKey = z.array(z.any()); 26 | const storedExactKey = z.object({ 27 | kind: z.literal("exact"), 28 | value: storedIndexKey, 29 | }); 30 | const storedMaximalKey = z.object({ 31 | kind: z.literal("successor"), 32 | value: z.array(z.never()), 33 | }); 34 | const storedUpperBound = z.union([storedExactKey, storedMaximalKey]); 35 | 36 | export const storedPageValidator = z.object({ 37 | table: z.string(), 38 | index: z.string(), 39 | convexSubscriptionId: z.string(), 40 | serializedLowerBound: z.string(), 41 | lowerBound: storedLowerBound, 42 | upperBound: storedUpperBound, 43 | documents: z.array(z.any()), 44 | }); 45 | export type StoredPage = z.infer; 46 | 47 | export function pageToStoredPage(page: Page): StoredPage | null { 48 | if (page.state.kind === "loading") { 49 | return null; 50 | } 51 | return storedPageValidator.parse({ 52 | table: page.tableName, 53 | index: page.indexName, 54 | convexSubscriptionId: page.convexSubscriptionId, 55 | serializedLowerBound: JSON.stringify(page.state.value.lowerBound), 56 | lowerBound: page.state.value.lowerBound, 57 | upperBound: page.state.value.upperBound, 58 | documents: page.state.value.results, 59 | }); 60 | } 61 | 62 | export function storedPageToPage(storedPage: StoredPage): Page { 63 | return { 64 | tableName: storedPage.table, 65 | indexName: storedPage.index, 66 | convexSubscriptionId: 67 | storedPage.convexSubscriptionId as ConvexSubscriptionId, 68 | state: { 69 | kind: "loaded", 70 | value: { 71 | lowerBound: storedPage.lowerBound as LowerBound, 72 | upperBound: storedPage.upperBound as UpperBound, 73 | results: storedPage.documents, 74 | }, 75 | }, 76 | }; 77 | } 78 | 79 | export const storedMutationValidator = z.object({ 80 | mutationName: z.string(), 81 | mutationId: z.string(), 82 | mutationPath: z.string(), 83 | optUpdateArgs: z.any(), 84 | serverArgs: z.any(), 85 | }); 86 | export type StoredMutation = z.infer; 87 | 88 | export function mutationToStoredMutation( 89 | mutation: MutationInfo, 90 | ): StoredMutation { 91 | return storedMutationValidator.parse({ 92 | mutationName: mutation.mutationName, 93 | mutationId: mutation.mutationId, 94 | mutationPath: getFunctionName(mutation.mutationPath), 95 | optUpdateArgs: convexToJson(mutation.optUpdateArgs as any), 96 | serverArgs: convexToJson(mutation.serverArgs as any), 97 | }); 98 | } 99 | 100 | export function storedMutationToMutation( 101 | storedMutation: StoredMutation, 102 | ): MutationInfo { 103 | return { 104 | mutationName: storedMutation.mutationName, 105 | mutationId: storedMutation.mutationId as MutationId, 106 | mutationPath: makeFunctionReference(storedMutation.mutationPath), 107 | optUpdateArgs: jsonToConvex(storedMutation.optUpdateArgs) as any, 108 | serverArgs: jsonToConvex(storedMutation.serverArgs) as any, 109 | }; 110 | } 111 | 112 | export const followerMessage = z.discriminatedUnion("type", [ 113 | z.object({ 114 | type: z.literal("join"), 115 | clientId: z.string(), 116 | name: z.string(), 117 | address: z.string(), 118 | }), 119 | z.object({ 120 | type: z.literal("persist"), 121 | clientId: z.string(), 122 | persistId: z.string(), 123 | mutationInfos: z.array(storedMutationValidator), 124 | pages: z.array(storedPageValidator), 125 | }), 126 | ]); 127 | export type FollowerMessage = z.infer; 128 | 129 | export const leaderMessage = z.discriminatedUnion("type", [ 130 | z.object({ 131 | type: z.literal("joinResult"), 132 | requestingClientId: z.string(), 133 | leaderClientId: z.string(), 134 | result: z.discriminatedUnion("type", [ 135 | z.object({ 136 | type: z.literal("success"), 137 | pages: z.array(storedPageValidator), 138 | mutations: z.array(z.any()), 139 | }), 140 | z.object({ 141 | type: z.literal("failure"), 142 | error: z.string(), 143 | }), 144 | ]), 145 | }), 146 | z.object({ 147 | type: z.literal("persistResult"), 148 | requestingClientId: z.string(), 149 | leaderClientId: z.string(), 150 | persistId: z.string(), 151 | result: z.discriminatedUnion("type", [ 152 | z.object({ 153 | type: z.literal("success"), 154 | }), 155 | z.object({ 156 | type: z.literal("failure"), 157 | error: z.string(), 158 | }), 159 | ]), 160 | }), 161 | ]); 162 | export type LeaderMessage = z.infer; 163 | -------------------------------------------------------------------------------- /curvilinear/src/assets/icons/slack.svg: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------