├── app-todos ├── .npmrc ├── app │ ├── tailwind.css │ ├── map-sqlite-resultset.ts │ ├── utils.test.ts │ ├── singleton.server.ts │ ├── entry.client.tsx │ ├── routes │ │ ├── healthcheck.tsx │ │ └── todos.$todosId.tsx │ ├── root.tsx │ ├── db.server.ts │ ├── utils.ts │ └── entry.server.tsx ├── .dockerignore ├── .env.example ├── remix.env.d.ts ├── .prettierignore ├── public │ └── favicon.ico ├── postcss.config.js ├── test │ └── setup-test-env.ts ├── .gitignore ├── tailwind.config.ts ├── remix.config.js ├── .gitpod.Dockerfile ├── vitest.config.ts ├── .eslintrc.js ├── tsconfig.json ├── fly.toml ├── Dockerfile ├── .gitpod.yml ├── package.json └── .github │ └── workflows │ └── deploy.yml ├── app-admin ├── src │ ├── vite-env.d.ts │ ├── main.tsx │ ├── index.css │ ├── App.css │ ├── assets │ │ └── react.svg │ └── App.tsx ├── vite.config.ts ├── tsconfig.node.json ├── .gitignore ├── index.html ├── .eslintrc.cjs ├── tsconfig.json ├── package.json └── public │ └── vite.svg ├── .prettierrc ├── .dockerignore ├── server ├── map-sqlite-resultset.ts ├── tsconfig.json ├── package.json ├── __snapshots__ │ └── mutators.test.ts.snap ├── .gitignore ├── index.ts ├── mutators.test.ts ├── mutators.ts ├── trpc.ts └── pnpm-lock.yaml ├── machines ├── package.json ├── __snapshots__ │ └── machines.test.ts.snap ├── ping-pong.ts ├── client.ts ├── server.ts ├── machines.test.ts └── pnpm-lock.yaml ├── package.json ├── README.md ├── fly.toml ├── .eslintrc.js ├── LICENSE ├── Dockerfile └── .gitignore /app-todos/.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /app-admin/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "tabWidth": 2 5 | } 6 | -------------------------------------------------------------------------------- /app-todos/app/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /app-todos/.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | *.log 3 | .DS_Store 4 | .env 5 | /.cache 6 | /public/build 7 | /build 8 | -------------------------------------------------------------------------------- /app-todos/.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="file:./data.db?connection_limit=1" 2 | SESSION_SECRET="super-duper-s3cret" 3 | -------------------------------------------------------------------------------- /app-todos/remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /app-todos/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | .env 6 | 7 | /app/styles/tailwind.css 8 | -------------------------------------------------------------------------------- /app-todos/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KyleAMathews/multi-tenancy-saas-demo/HEAD/app-todos/public/favicon.ico -------------------------------------------------------------------------------- /app-todos/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /app-todos/test/setup-test-env.ts: -------------------------------------------------------------------------------- 1 | import { installGlobals } from "@remix-run/node"; 2 | import "@testing-library/jest-dom/extend-expect"; 3 | 4 | installGlobals(); 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /server/node_modules 3 | /server/.cache 4 | /app-admin/node_modules 5 | *.log 6 | .DS_Store 7 | .env 8 | /.cache 9 | /public/build 10 | /build 11 | -------------------------------------------------------------------------------- /app-todos/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | .env 6 | 7 | /cypress/screenshots 8 | /cypress/videos 9 | /prisma/data.db 10 | /prisma/data.db-journal 11 | -------------------------------------------------------------------------------- /app-admin/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react-swc' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /app-todos/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: ["./app/**/*.{js,jsx,ts,tsx}"], 5 | theme: { 6 | extend: {}, 7 | }, 8 | plugins: [], 9 | } satisfies Config; 10 | -------------------------------------------------------------------------------- /app-todos/remix.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@remix-run/dev').AppConfig} */ 2 | module.exports = { 3 | cacheDirectory: "./node_modules/.cache/remix", 4 | ignoredRouteFiles: ["**/.*", "**/*.test.{ts,tsx}"], 5 | serverModuleFormat: "cjs", 6 | }; 7 | -------------------------------------------------------------------------------- /server/map-sqlite-resultset.ts: -------------------------------------------------------------------------------- 1 | export function mapResultSet(rs) { 2 | return rs.rows.map((row) => { 3 | const obj = {} 4 | 5 | rs.columns.forEach((name, i) => { 6 | obj[name] = row[i] 7 | }) 8 | 9 | return obj 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /app-todos/app/map-sqlite-resultset.ts: -------------------------------------------------------------------------------- 1 | export function mapResultSet(rs) { 2 | return rs.rows.map((row) => { 3 | const obj = {} 4 | 5 | rs.columns.forEach((name, i) => { 6 | obj[name] = row[i] 7 | }) 8 | 9 | return obj 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /app-todos/.gitpod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gitpod/workspace-full 2 | 3 | # Install Fly 4 | RUN curl -L https://fly.io/install.sh | sh 5 | ENV FLYCTL_INSTALL="/home/gitpod/.fly" 6 | ENV PATH="$FLYCTL_INSTALL/bin:$PATH" 7 | 8 | # Install GitHub CLI 9 | RUN brew install gh 10 | -------------------------------------------------------------------------------- /app-admin/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /machines/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@libsql/client": "^0.3.5", 4 | "ejs": "^3.1.9", 5 | "node-sql-parser": "^4.11.0", 6 | "uuid": "^9.0.1", 7 | "yjs": "^13.6.8" 8 | }, 9 | "devDependencies": { 10 | "vitest": "^0.34.5" 11 | }, 12 | "scripts": { 13 | "test": "vitest" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app-admin/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /app-admin/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app-admin/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { SituatedProvider, loader } from 'situated' 4 | import App from './App.tsx' 5 | import './index.css' 6 | 7 | async function main() { 8 | await loader() 9 | ReactDOM.createRoot(document.getElementById(`root`) as HTMLElement).render( 10 | 11 | 12 | 13 | ) 14 | } 15 | 16 | main() 17 | -------------------------------------------------------------------------------- /app-todos/vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | import react from "@vitejs/plugin-react"; 5 | import tsconfigPaths from "vite-tsconfig-paths"; 6 | import { defineConfig } from "vitest/config"; 7 | 8 | export default defineConfig({ 9 | plugins: [react(), tsconfigPaths()], 10 | test: { 11 | globals: true, 12 | environment: "happy-dom", 13 | setupFiles: ["./test/setup-test-env.ts"], 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /app-todos/app/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { validateEmail } from "./utils"; 2 | 3 | test("validateEmail returns false for non-emails", () => { 4 | expect(validateEmail(undefined)).toBe(false); 5 | expect(validateEmail(null)).toBe(false); 6 | expect(validateEmail("")).toBe(false); 7 | expect(validateEmail("not-an-email")).toBe(false); 8 | expect(validateEmail("n@")).toBe(false); 9 | }); 10 | 11 | test("validateEmail returns true for emails", () => { 12 | expect(validateEmail("kody@example.com")).toBe(true); 13 | }); 14 | -------------------------------------------------------------------------------- /app-admin/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /app-todos/app/singleton.server.ts: -------------------------------------------------------------------------------- 1 | // Since the dev server re-requires the bundle, do some shenanigans to make 2 | // certain things persist across that 😆 3 | // Borrowed/modified from https://github.com/jenseng/abuse-the-platform/blob/2993a7e846c95ace693ce61626fa072174c8d9c7/app/utils/singleton.ts 4 | 5 | export const singleton = ( 6 | name: string, 7 | valueFactory: () => Value, 8 | ): Value => { 9 | const g = global as any; 10 | g.__singletons ??= {}; 11 | g.__singletons[name] ??= valueFactory(); 12 | return g.__singletons[name]; 13 | }; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multi-tenancy-saas-demo", 3 | "dependencies": { 4 | "@trpc/server": "^10.38.5", 5 | "trpc-yjs": "^0.0.1" 6 | }, 7 | "devDependencies": { 8 | "@typescript-eslint/eslint-plugin": "^6.7.0", 9 | "@typescript-eslint/parser": "^6.7.0", 10 | "eslint": "^8.49.0", 11 | "eslint-config-prettier": "^9.0.0", 12 | "eslint-config-react": "^1.1.7", 13 | "eslint-plugin-prettier": "^5.0.0", 14 | "eslint-plugin-react": "^7.33.2", 15 | "prettier": "^3.0.3", 16 | "typescript": "^5.2.2" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # multi-tenancy-saas-demo 2 | 3 | Demo of building a multi-tenancy saas app w/ Turso. 4 | 5 | - app-admin: client code for an internal-only admin app. Something every SaaS tool needs. It shows information about each app instance and lets you create new ones or clone existing ones (useful for setting up demos). It communicates with Turso to manage db instances and reads/writes to an admin database. 6 | - server: API for the admin app 7 | - app-todos: the app-server. It uses an embedded replica to pull down the admin database so it can correctly serve each app instance 8 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | app = "admin-todos-saas" 2 | primary_region = "sea" 3 | kill_signal = "SIGINT" 4 | kill_timeout = "5s" 5 | 6 | [[services]] 7 | protocol = "tcp" 8 | internal_port = 3000 9 | processes = ["app"] 10 | 11 | [[services.ports]] 12 | port = 80 13 | handlers = ["http"] 14 | force_https = true 15 | 16 | [[services.ports]] 17 | port = 443 18 | handlers = ["tls", "http"] 19 | [services.concurrency] 20 | type = "connections" 21 | hard_limit = 25 22 | soft_limit = 20 23 | 24 | [[services.tcp_checks]] 25 | interval = "15s" 26 | timeout = "2s" 27 | grace_period = "1s" 28 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext"], 4 | "module": "esnext", 5 | "target": "esnext", 6 | "moduleResolution": "bundler", 7 | "moduleDetection": "force", 8 | "allowImportingTsExtensions": true, 9 | "noEmit": true, 10 | "composite": true, 11 | "strict": true, 12 | "downlevelIteration": true, 13 | "skipLibCheck": true, 14 | "jsx": "preserve", 15 | "allowSyntheticDefaultImports": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "allowJs": true, 18 | "types": [ 19 | "bun-types" // add Bun global 20 | ] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app-todos/.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | root: true, 4 | extends: [ 5 | "@remix-run/eslint-config", 6 | "@remix-run/eslint-config/node", 7 | "@remix-run/eslint-config/jest-testing-library", 8 | "prettier", 9 | ], 10 | env: { 11 | "cypress/globals": true, 12 | }, 13 | plugins: ["cypress"], 14 | // we're using vitest which has a very similar API to jest 15 | // (so the linting plugins work nicely), but it means we have to explicitly 16 | // set the jest version. 17 | settings: { 18 | jest: { 19 | version: 28, 20 | }, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /machines/__snapshots__/machines.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`create/delete dbs 1`] = ` 4 | { 5 | "foo": true, 6 | } 7 | `; 8 | 9 | exports[`create/delete dbs 2`] = ` 10 | [ 11 | { 12 | "email": "alice@example.org", 13 | "id": 1, 14 | "name": "Alice", 15 | }, 16 | ] 17 | `; 18 | 19 | exports[`update a robot name 1`] = ` 20 | { 21 | "done": true, 22 | "error": false, 23 | "mutator": "updateRobotName", 24 | "request": { 25 | "id": "123", 26 | "name": "beep", 27 | }, 28 | "response": { 29 | "ok": true, 30 | }, 31 | "value": "responded", 32 | } 33 | `; 34 | -------------------------------------------------------------------------------- /machines/ping-pong.ts: -------------------------------------------------------------------------------- 1 | import { createMachine } from "xstate"; 2 | 3 | export const machine = createMachine( 4 | { 5 | id: `Ping/Pong`, 6 | initial: `Pinged`, 7 | states: { 8 | Pinged: { 9 | on: { 10 | ON_SERVER: { 11 | target: `Ponged`, 12 | }, 13 | }, 14 | }, 15 | Ponged: { 16 | type: `final`, 17 | }, 18 | }, 19 | schema: { events: {} as { type: `ON_SERVER` } }, 20 | predictableActionArguments: true, 21 | preserveActionOrder: true, 22 | }, 23 | { 24 | actions: {}, 25 | services: {}, 26 | guards: {}, 27 | delays: {}, 28 | }, 29 | ); 30 | -------------------------------------------------------------------------------- /app-todos/app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/docs/en/main/file-conventions/entry.client 5 | */ 6 | 7 | import { RemixBrowser } from "@remix-run/react"; 8 | import { startTransition, StrictMode } from "react"; 9 | import { hydrateRoot } from "react-dom/client"; 10 | 11 | startTransition(() => { 12 | hydrateRoot( 13 | document, 14 | 15 | 16 | , 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | `eslint:recommended`, 9 | `plugin:@typescript-eslint/recommended`, 10 | `plugin:react/recommended`, 11 | `plugin:prettier/recommended`, 12 | ], 13 | parserOptions: { 14 | ecmaVersion: 2022, 15 | requireConfigFile: false, 16 | sourceType: `module`, 17 | ecmaFeatures: { 18 | jsx: true, 19 | }, 20 | }, 21 | settings: { 22 | react: { 23 | version: `detect`, 24 | }, 25 | }, 26 | parser: '@typescript-eslint/parser', 27 | plugins: [`react`, `prettier`], 28 | rules: { 29 | quotes: [`error`, `backtick`], 30 | }, 31 | } 32 | -------------------------------------------------------------------------------- /app-admin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /app-todos/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["./cypress", "./cypress.config.ts"], 3 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 4 | "compilerOptions": { 5 | "lib": ["DOM", "DOM.Iterable", "ES2020"], 6 | "types": ["vitest/globals"], 7 | "isolatedModules": true, 8 | "esModuleInterop": true, 9 | "jsx": "react-jsx", 10 | "module": "CommonJS", 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "target": "ES2020", 14 | "strict": true, 15 | "allowJs": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "~/*": ["./app/*"] 20 | }, 21 | "skipLibCheck": true, 22 | 23 | // Remix takes care of building everything in `remix build`. 24 | "noEmit": true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "module": "index.ts", 4 | "type": "module", 5 | "devDependencies": { 6 | "bun-types": "latest", 7 | "vitest": "^0.34.5" 8 | }, 9 | "scripts": { 10 | "dev": "tsx index.ts", 11 | "build": "cd ../app-admin && npm run build && cp -r dist ../server/", 12 | "start": "NODE_ENV=production tsx index.ts", 13 | "test": "vitest" 14 | }, 15 | "peerDependencies": { 16 | "typescript": "^5.0.0" 17 | }, 18 | "dependencies": { 19 | "@coffeeandfun/google-profanity-words": "^2.1.0", 20 | "@libsql/client": "^0.3.5", 21 | "@trpc/server": "^10.38.5", 22 | "cors": "^2.8.5", 23 | "express": "^4.18.2", 24 | "fs-extra": "^11.1.1", 25 | "node-sql-parser": "^4.11.0", 26 | "situated": "^0.0.1", 27 | "trpc-yjs": "^0.0.6", 28 | "tsx": "^3.13.0", 29 | "ws": "^8.14.2", 30 | "yjs": "^13.6.8", 31 | "zod": "^3.22.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app-admin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@trpc/client": "^10.38.5", 14 | "react": "^18.2.0", 15 | "react-aria-components": "1.0.0-beta.0", 16 | "react-dom": "^18.2.0", 17 | "situated": "^0.0.6", 18 | "timeago.js": "^4.0.2", 19 | "trpc-yjs": "^0.0.6" 20 | }, 21 | "devDependencies": { 22 | "@types/react": "^18.2.15", 23 | "@types/react-dom": "^18.2.7", 24 | "@typescript-eslint/eslint-plugin": "^6.0.0", 25 | "@typescript-eslint/parser": "^6.0.0", 26 | "@vitejs/plugin-react-swc": "^3.3.2", 27 | "eslint": "^8.45.0", 28 | "eslint-plugin-react-hooks": "^4.6.0", 29 | "eslint-plugin-react-refresh": "^0.4.3", 30 | "typescript": "^5.0.2", 31 | "vite": "^4.4.5" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app-todos/app/routes/healthcheck.tsx: -------------------------------------------------------------------------------- 1 | // learn more: https://fly.io/docs/reference/configuration/#services-http_checks 2 | import type { LoaderFunctionArgs } from "@remix-run/node"; 3 | 4 | import { getDb } from "~/db.server"; 5 | 6 | export const loader = async ({ request }: LoaderFunctionArgs) => { 7 | const host = 8 | request.headers.get("X-Forwarded-Host") ?? request.headers.get("host"); 9 | 10 | try { 11 | const db = getDb(`do-not-delete`) 12 | const url = new URL("/", `http://${host}`); 13 | // if we can connect to the database and make a simple query 14 | // and make a HEAD request to ourselves, then we're good. 15 | await Promise.all([ 16 | db.execute(`select * from todo`), 17 | fetch(url.toString(), { method: "HEAD" }).then((r) => { 18 | if (!r.ok) return Promise.reject(r); 19 | }), 20 | ]); 21 | return new Response("OK"); 22 | } catch (error: unknown) { 23 | console.log("healthcheck ❌", { error }); 24 | return new Response("ERROR", { status: 500 }); 25 | } 26 | }; 27 | 28 | -------------------------------------------------------------------------------- /app-todos/app/root.tsx: -------------------------------------------------------------------------------- 1 | import { cssBundleHref } from "@remix-run/css-bundle"; 2 | import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node"; 3 | import { json } from "@remix-run/node"; 4 | import { 5 | Links, 6 | LiveReload, 7 | Meta, 8 | Outlet, 9 | Scripts, 10 | ScrollRestoration, 11 | } from "@remix-run/react"; 12 | 13 | import stylesheet from "~/tailwind.css"; 14 | 15 | export const links: LinksFunction = () => [ 16 | { rel: "stylesheet", href: stylesheet }, 17 | ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), 18 | ]; 19 | 20 | export default function App() { 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /app-todos/fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for app-server-todos-saas on 2023-09-26T15:51:05-07:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = "app-server-todos-saas" 7 | primary_region = "sea" 8 | kill_signal = "SIGINT" 9 | kill_timeout = "5s" 10 | 11 | [build] 12 | 13 | [[services]] 14 | protocol = "tcp" 15 | internal_port = 8080 16 | processes = ["app"] 17 | 18 | [[services.ports]] 19 | port = 80 20 | handlers = ["http"] 21 | force_https = true 22 | 23 | [[services.ports]] 24 | port = 443 25 | handlers = ["tls", "http"] 26 | [services.concurrency] 27 | type = "connections" 28 | hard_limit = 25 29 | soft_limit = 20 30 | 31 | [[services.tcp_checks]] 32 | interval = "15s" 33 | timeout = "2s" 34 | grace_period = "1s" 35 | 36 | [[services.http_checks]] 37 | interval = "10s" 38 | timeout = "2s" 39 | grace_period = "5s" 40 | method = "get" 41 | path = "/healthcheck" 42 | protocol = "http" 43 | tls_skip_verify = false 44 | -------------------------------------------------------------------------------- /server/__snapshots__/mutators.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`serverConfig > clone 1`] = ` 4 | [ 5 | "url", 6 | "name", 7 | ] 8 | `; 9 | 10 | exports[`serverConfig > clone 2`] = ` 11 | [ 12 | "url", 13 | "authToken", 14 | "state", 15 | "name", 16 | "total", 17 | "completed", 18 | "updatedAt", 19 | ] 20 | `; 21 | 22 | exports[`serverConfig > createDb 1`] = ` 23 | [ 24 | "url", 25 | "name", 26 | ] 27 | `; 28 | 29 | exports[`serverConfig > createDb 2`] = ` 30 | [ 31 | "url", 32 | "authToken", 33 | "state", 34 | "name", 35 | "total", 36 | "completed", 37 | "updatedAt", 38 | ] 39 | `; 40 | 41 | exports[`serverConfig > deleteDb 1`] = ` 42 | [ 43 | "name", 44 | ] 45 | `; 46 | 47 | exports[`serverConfig > deleteDb 2`] = ` 48 | [ 49 | "name", 50 | ] 51 | `; 52 | 53 | exports[`update a robot name 1`] = ` 54 | { 55 | "done": true, 56 | "error": false, 57 | "mutator": "updateRobotName", 58 | "request": { 59 | "id": "123", 60 | "name": "beep", 61 | }, 62 | "response": { 63 | "ok": true, 64 | }, 65 | "value": "responded", 66 | } 67 | `; 68 | -------------------------------------------------------------------------------- /app-todos/Dockerfile: -------------------------------------------------------------------------------- 1 | # base node image 2 | FROM node:20-bullseye-slim as base 3 | 4 | # set for base and all layer that inherit from it 5 | ENV NODE_ENV production 6 | 7 | # Install all node_modules, including dev dependencies 8 | FROM base as deps 9 | 10 | WORKDIR /myapp 11 | 12 | ADD package.json package-lock.json .npmrc ./ 13 | RUN npm install --include=dev 14 | 15 | # Setup production node_modules 16 | FROM base as production-deps 17 | 18 | WORKDIR /myapp 19 | 20 | COPY --from=deps /myapp/node_modules /myapp/node_modules 21 | ADD package.json package-lock.json .npmrc ./ 22 | RUN npm prune --omit=dev 23 | 24 | # Build the app 25 | FROM base as build 26 | 27 | WORKDIR /myapp 28 | 29 | COPY --from=deps /myapp/node_modules /myapp/node_modules 30 | 31 | ADD . . 32 | RUN npm run build 33 | 34 | # Finally, build the production image with minimal footprint 35 | FROM base 36 | 37 | ENV PORT="8080" 38 | ENV NODE_ENV="production" 39 | 40 | WORKDIR /myapp 41 | 42 | COPY --from=production-deps /myapp/node_modules /myapp/node_modules 43 | 44 | COPY --from=build /myapp/build /myapp/build 45 | COPY --from=build /myapp/public /myapp/public 46 | COPY --from=build /myapp/package.json /myapp/package.json 47 | 48 | CMD [ "npm", "run", "start" ] 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /app-admin/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | -webkit-text-size-adjust: 100%; 15 | } 16 | 17 | a { 18 | font-weight: 500; 19 | color: #646cff; 20 | text-decoration: inherit; 21 | } 22 | a:hover { 23 | color: #535bf2; 24 | } 25 | 26 | h1 { 27 | font-size: 3.2em; 28 | line-height: 1.1; 29 | } 30 | 31 | button { 32 | border-radius: 8px; 33 | border: 1px solid transparent; 34 | padding: 0.6em 1.2em; 35 | font-size: 1em; 36 | font-weight: 500; 37 | font-family: inherit; 38 | background-color: #1a1a1a; 39 | cursor: pointer; 40 | transition: border-color 0.25s; 41 | } 42 | button:hover { 43 | border-color: #646cff; 44 | } 45 | button:focus, 46 | button:focus-visible { 47 | outline: 4px auto -webkit-focus-ring-color; 48 | } 49 | 50 | @media (prefers-color-scheme: light) { 51 | :root { 52 | color: #213547; 53 | background-color: #ffffff; 54 | } 55 | a:hover { 56 | color: #747bff; 57 | } 58 | button { 59 | background-color: #f9f9f9; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /machines/client.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from "uuid" 2 | 3 | export async function createRequest({ doc, mutator, request }) { 4 | const requests = doc.getArray(`requests`) 5 | const requestId = uuidv4() 6 | let resolveFunc, rejectFunc 7 | 8 | const promise = new Promise((resolve, reject) => { 9 | // Storing the resolve and reject functions for later use. 10 | resolveFunc = resolve 11 | rejectFunc = reject 12 | }) 13 | function observe(event) { 14 | const inserted = event.changes.delta.find( 15 | (item) => Object.keys(item)[0] === `insert` 16 | ) 17 | const state = inserted.insert[0] 18 | // TODO only do this in case of user-directed timeout or network 19 | // disconnect errors e.g. normally we're fine just waiting to go online 20 | // but the app might want to error immediately if we disconnect or are offline. 21 | // if (state.error) { 22 | // requests.unobserve(observe) 23 | // return rejectFunc(state) 24 | // } 25 | if (state.done) { 26 | requests.unobserve(observe) 27 | return resolveFunc(state) 28 | } 29 | } 30 | 31 | requests.observe(observe) 32 | requests.push([ 33 | { 34 | mutator, 35 | value: `requested`, 36 | id: requestId, 37 | done: false, 38 | clientCreate: new Date().toJSON(), 39 | request, 40 | response: {}, 41 | }, 42 | ]) 43 | 44 | return promise 45 | } 46 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # base node image 2 | FROM node:20-bullseye-slim as base 3 | SHELL ["/bin/bash", "-c"] 4 | ENV PNPM_HOME="/pnpm" 5 | ENV PATH="$PNPM_HOME:$PATH" 6 | RUN corepack enable 7 | ENV TURSO_AUTH_TOKEN=$TURSO_AUTH_TOKEN 8 | 9 | 10 | # set for base and all layer that inherit from it 11 | ENV NODE_ENV production 12 | 13 | # Setup production node_modules 14 | FROM base as app 15 | 16 | WORKDIR /root-app/app 17 | 18 | ADD app-admin/ . 19 | ADD machines ../machines 20 | ENV NODE_ENV=development 21 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 22 | ENV NODE_ENV=production 23 | RUN pnpm build 24 | 25 | # Install all node_modules, including dev dependencies 26 | FROM base as serverDeps 27 | 28 | RUN apt-get update \ 29 | && apt-get install -y curl \ 30 | && rm -rf /var/lib/apt/lists/* 31 | 32 | RUN curl -sSfL https://gist.githubusercontent.com/KyleAMathews/e7678b4d13adb24e6b5331a89e3b30a8/raw/d3a1b1ee70313b30e92fa092ce1b330fad8be711/install-turso.sh | bash 33 | RUN source /root/.bashrc 34 | RUN /root/.turso/turso --version 35 | 36 | WORKDIR /root-app/server 37 | 38 | ADD server/ . 39 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 40 | ADD machines ../machines 41 | add server/map-sqlite-resultset.ts package.json .. 42 | 43 | COPY --from=app /root-app/app/dist dist 44 | 45 | ENTRYPOINT ["/bin/bash", "-c", "source /root/.bashrc && npm run start"] 46 | # CMD [ "npm", "run", "start" ] 47 | -------------------------------------------------------------------------------- /app-todos/app/db.server.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@libsql/client"; 2 | import { singleton } from "./singleton.server"; 3 | import * as Y from "yjs"; 4 | import { WebsocketProvider } from "y-websocket"; 5 | import { mapResultSet } from "./map-sqlite-resultset"; 6 | 7 | console.log(`getting ydoc`); 8 | 9 | console.log({ 10 | url: `file:admin.db`, 11 | syncUrl: process.env.TURSO_URL, 12 | authToken: process.env.TURSO_AUTH_TOKEN, 13 | }) 14 | 15 | const adminDb = createClient({ 16 | url: `file:admin.db`, 17 | syncUrl: process.env.TURSO_URL, 18 | authToken: process.env.TURSO_AUTH_TOKEN, 19 | }); 20 | 21 | const syncedForDbs = new Set(); 22 | const dbs = new Map(); 23 | async function syncAdminDb(dbName) { 24 | if (!syncedForDbs.has(dbName)) { 25 | console.time(`sync db from turso ${dbName}`); 26 | await adminDb.sync(); 27 | console.timeEnd(`sync db from turso ${dbName}`); 28 | syncedForDbs.add(dbName); 29 | } 30 | 31 | const results = await adminDb.execute(`SELECT * from dbs`); 32 | const dbRows = mapResultSet(results); 33 | dbRows.forEach((dbInfo) => { 34 | dbs.set(dbInfo.name, dbInfo); 35 | }); 36 | } 37 | 38 | syncAdminDb(`initial`); 39 | 40 | const getDb = (dbName) => 41 | singleton(`getDb${dbName}`, () => { 42 | console.log(`get db singleton ${dbName}`); 43 | const db = dbs.get(dbName); 44 | return createClient({ url: db.url, authToken: db.authToken }); 45 | }); 46 | 47 | export { getDb, syncAdminDb }; 48 | -------------------------------------------------------------------------------- /app-admin/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app-todos/app/utils.ts: -------------------------------------------------------------------------------- 1 | import { useMatches } from "@remix-run/react"; 2 | import { useMemo } from "react"; 3 | 4 | import type { User } from "~/models/user.server"; 5 | 6 | const DEFAULT_REDIRECT = "/"; 7 | 8 | /** 9 | * This should be used any time the redirect path is user-provided 10 | * (Like the query string on our login/signup pages). This avoids 11 | * open-redirect vulnerabilities. 12 | * @param {string} to The redirect destination 13 | * @param {string} defaultRedirect The redirect to use if the to is unsafe. 14 | */ 15 | export function safeRedirect( 16 | to: FormDataEntryValue | string | null | undefined, 17 | defaultRedirect: string = DEFAULT_REDIRECT, 18 | ) { 19 | if (!to || typeof to !== "string") { 20 | return defaultRedirect; 21 | } 22 | 23 | if (!to.startsWith("/") || to.startsWith("//")) { 24 | return defaultRedirect; 25 | } 26 | 27 | return to; 28 | } 29 | 30 | /** 31 | * This base hook is used in other hooks to quickly search for specific data 32 | * across all loader data using useMatches. 33 | * @param {string} id The route id 34 | * @returns {JSON|undefined} The router data or undefined if not found 35 | */ 36 | export function useMatchesData( 37 | id: string, 38 | ): Record | undefined { 39 | const matchingRoutes = useMatches(); 40 | const route = useMemo( 41 | () => matchingRoutes.find((route) => route.id === id), 42 | [matchingRoutes, id], 43 | ); 44 | return route?.data as Record; 45 | } 46 | -------------------------------------------------------------------------------- /app-todos/.gitpod.yml: -------------------------------------------------------------------------------- 1 | # https://www.gitpod.io/docs/config-gitpod-file 2 | 3 | image: 4 | file: .gitpod.Dockerfile 5 | 6 | ports: 7 | - port: 3000 8 | onOpen: notify 9 | 10 | tasks: 11 | - name: Restore .env file 12 | command: | 13 | if [ -f .env ]; then 14 | # If this workspace already has a .env, don't override it 15 | # Local changes survive a workspace being opened and closed 16 | # but they will not persist between separate workspaces for the same repo 17 | 18 | echo "Found .env in workspace" 19 | else 20 | # There is no .env 21 | if [ ! -n "${ENV}" ]; then 22 | # There is no $ENV from a previous workspace 23 | # Default to the example .env 24 | echo "Setting example .env" 25 | 26 | cp .env.example .env 27 | else 28 | # After making changes to .env, run this line to persist it to $ENV 29 | # eval $(gp env -e ENV="$(base64 .env | tr -d '\n')") 30 | # 31 | # Environment variables set this way are shared between all your workspaces for this repo 32 | # The lines below will read $ENV and print a .env file 33 | 34 | echo "Restoring .env from Gitpod" 35 | 36 | echo "${ENV}" | base64 -d | tee .env > /dev/null 37 | fi 38 | fi 39 | 40 | - init: npm install 41 | command: npm run setup && npm run dev 42 | 43 | vscode: 44 | extensions: 45 | - ms-azuretools.vscode-docker 46 | - esbenp.prettier-vscode 47 | - dbaeumer.vscode-eslint 48 | - bradlc.vscode-tailwindcss 49 | -------------------------------------------------------------------------------- /machines/server.ts: -------------------------------------------------------------------------------- 1 | import * as Y from "yjs" 2 | 3 | const isServerSync = true 4 | export function listen({ doc, serverConfig }) { 5 | const requests = doc.getArray(`requests`) 6 | requests.observe(async (event) => { 7 | const inserted = event.changes.delta.find( 8 | (item) => Object.keys(item)[0] === `insert` 9 | ) 10 | const retain = event.changes.delta.find( 11 | (item) => Object.keys(item)[0] === `retain` 12 | ) 13 | const itemArray = retain?.retain || 0 14 | const state = event.target.get(itemArray) 15 | console.log({ state }) 16 | if (state.done !== true) { 17 | if ( 18 | serverConfig.mutators.hasOwnProperty(state.mutator) && 19 | serverConfig.mutators[state.mutator] instanceof Function 20 | ) { 21 | const mutatorFunc = await serverConfig.mutators[state.mutator]({ 22 | state, 23 | doc, 24 | }) 25 | doc.transact(() => { 26 | state.response = mutatorFunc() 27 | 28 | if (typeof state.response?.error !== `undefined`) { 29 | state.error = true 30 | } else { 31 | state.error = false 32 | } 33 | 34 | state.value = `responded` 35 | state.done = true 36 | state.serverResponded = new Date().toJSON() 37 | requests.delete(itemArray, 1) 38 | requests.insert(itemArray, [state]) 39 | }) 40 | } else { 41 | console.log(`bad request`) 42 | doc.transact(() => { 43 | state.error = true 44 | state.response = { 45 | error: `A mutator by that name does not exist`, 46 | } 47 | }) 48 | } 49 | } 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | admin.db 132 | admin.db-shm 133 | admin.db-wal 134 | -------------------------------------------------------------------------------- /app-todos/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app-todos2-d042", 3 | "private": true, 4 | "sideEffects": false, 5 | "scripts": { 6 | "build": "remix build", 7 | "dev": "PORT=10000 remix dev -c \"npm run dev:serve\"", 8 | "dev:serve": "binode -- @remix-run/serve:remix-serve ./build/index.js", 9 | "format": "prettier --write .", 10 | "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", 11 | "setup": "prisma generate && prisma migrate deploy && prisma db seed", 12 | "start": "remix-serve ./build/index.js", 13 | "test": "vitest", 14 | "test:e2e:dev": "start-server-and-test dev http://localhost:3000 \"npx cypress open\"", 15 | "pretest:e2e:run": "npm run build", 16 | "test:e2e:run": "cross-env PORT=8811 start-server-and-test http://localhost:8811 \"npx cypress run\"", 17 | "typecheck": "tsc && tsc -p cypress", 18 | "validate": "run-p \"test -- --run\" lint typecheck test:e2e:run" 19 | }, 20 | "prettier": {}, 21 | "eslintIgnore": [ 22 | "/node_modules", 23 | "/build", 24 | "/public/build" 25 | ], 26 | "dependencies": { 27 | "@libsql/client": "^0.3.5", 28 | "@prisma/client": "^4.16.2", 29 | "@remix-run/css-bundle": "^2.0.0", 30 | "@remix-run/node": "^2.0.0", 31 | "@remix-run/react": "^2.0.0", 32 | "@remix-run/serve": "^2.0.0", 33 | "bcryptjs": "^2.4.3", 34 | "isbot": "^3.6.13", 35 | "react": "^18.2.0", 36 | "react-dom": "^18.2.0", 37 | "tiny-invariant": "^1.3.1", 38 | "ws": "^8.14.2", 39 | "y-websocket": "^1.5.0", 40 | "yjs": "^13.6.8" 41 | }, 42 | "devDependencies": { 43 | "@faker-js/faker": "^8.0.2", 44 | "@remix-run/dev": "^2.0.0", 45 | "@remix-run/eslint-config": "^2.0.0", 46 | "@testing-library/cypress": "^9.0.0", 47 | "@testing-library/jest-dom": "^5.17.0", 48 | "@types/bcryptjs": "^2.4.2", 49 | "@types/eslint": "^8.44.2", 50 | "@types/node": "^18.17.6", 51 | "@types/react": "^18.2.20", 52 | "@types/react-dom": "^18.2.7", 53 | "@vitejs/plugin-react": "^4.0.4", 54 | "@vitest/coverage-v8": "^0.34.2", 55 | "autoprefixer": "^10.4.15", 56 | "binode": "^1.0.5", 57 | "cookie": "^0.5.0", 58 | "cross-env": "^7.0.3", 59 | "cypress": "12.17.3", 60 | "eslint": "^8.47.0", 61 | "eslint-config-prettier": "^9.0.0", 62 | "eslint-plugin-cypress": "^2.14.0", 63 | "happy-dom": "^10.10.4", 64 | "msw": "^1.2.3", 65 | "npm-run-all": "^4.1.5", 66 | "postcss": "^8.4.28", 67 | "prettier": "3.0.2", 68 | "prettier-plugin-tailwindcss": "^0.5.3", 69 | "start-server-and-test": "^2.0.0", 70 | "tailwindcss": "^3.3.3", 71 | "ts-node": "^10.9.1", 72 | "tsconfig-paths": "^4.2.0", 73 | "typescript": "^5.1.6", 74 | "vite": "^4.4.9", 75 | "vite-tsconfig-paths": "^3.6.0", 76 | "vitest": "^0.34.2" 77 | }, 78 | "engines": { 79 | "node": ">=18.0.0" 80 | }, 81 | "prisma": { 82 | "seed": "ts-node --require tsconfig-paths/register prisma/seed.ts" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | 15 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 16 | 17 | # Runtime data 18 | 19 | pids 20 | _.pid 21 | _.seed 22 | \*.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | 30 | coverage 31 | \*.lcov 32 | 33 | # nyc test coverage 34 | 35 | .nyc_output 36 | 37 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 38 | 39 | .grunt 40 | 41 | # Bower dependency directory (https://bower.io/) 42 | 43 | bower_components 44 | 45 | # node-waf configuration 46 | 47 | .lock-wscript 48 | 49 | # Compiled binary addons (https://nodejs.org/api/addons.html) 50 | 51 | build/Release 52 | 53 | # Dependency directories 54 | 55 | node_modules/ 56 | jspm_packages/ 57 | 58 | # Snowpack dependency directory (https://snowpack.dev/) 59 | 60 | web_modules/ 61 | 62 | # TypeScript cache 63 | 64 | \*.tsbuildinfo 65 | 66 | # Optional npm cache directory 67 | 68 | .npm 69 | 70 | # Optional eslint cache 71 | 72 | .eslintcache 73 | 74 | # Optional stylelint cache 75 | 76 | .stylelintcache 77 | 78 | # Microbundle cache 79 | 80 | .rpt2_cache/ 81 | .rts2_cache_cjs/ 82 | .rts2_cache_es/ 83 | .rts2_cache_umd/ 84 | 85 | # Optional REPL history 86 | 87 | .node_repl_history 88 | 89 | # Output of 'npm pack' 90 | 91 | \*.tgz 92 | 93 | # Yarn Integrity file 94 | 95 | .yarn-integrity 96 | 97 | # dotenv environment variable files 98 | 99 | .env 100 | .env.development.local 101 | .env.test.local 102 | .env.production.local 103 | .env.local 104 | 105 | # parcel-bundler cache (https://parceljs.org/) 106 | 107 | .cache 108 | .parcel-cache 109 | 110 | # Next.js build output 111 | 112 | .next 113 | out 114 | 115 | # Nuxt.js build / generate output 116 | 117 | .nuxt 118 | dist 119 | 120 | # Gatsby files 121 | 122 | .cache/ 123 | 124 | # Comment in the public line in if your project uses Gatsby and not Next.js 125 | 126 | # https://nextjs.org/blog/next-9-1#public-directory-support 127 | 128 | # public 129 | 130 | # vuepress build output 131 | 132 | .vuepress/dist 133 | 134 | # vuepress v2.x temp and cache directory 135 | 136 | .temp 137 | .cache 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.\* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | -------------------------------------------------------------------------------- /app-admin/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 1rem; 5 | } 6 | 7 | .logo { 8 | height: 6em; 9 | padding: 1.5em; 10 | will-change: filter; 11 | transition: filter 300ms; 12 | } 13 | .logo:hover { 14 | filter: drop-shadow(0 0 2em #646cffaa); 15 | } 16 | .logo.react:hover { 17 | filter: drop-shadow(0 0 2em #61dafbaa); 18 | } 19 | 20 | @keyframes logo-spin { 21 | from { 22 | transform: rotate(0deg); 23 | } 24 | to { 25 | transform: rotate(360deg); 26 | } 27 | } 28 | 29 | @media (prefers-reduced-motion: no-preference) { 30 | a:nth-of-type(2) .logo { 31 | animation: logo-spin infinite 20s linear; 32 | } 33 | } 34 | 35 | .card { 36 | padding: 2em; 37 | } 38 | 39 | .read-the-docs { 40 | color: #888; 41 | } 42 | 43 | .react-aria-ModalOverlay { 44 | position: fixed; 45 | top: 0; 46 | left: 0; 47 | width: 100vw; 48 | height: var(--visual-viewport-height); 49 | background: rgba(0 0 0 / 0.5); 50 | display: flex; 51 | align-items: center; 52 | justify-content: center; 53 | 54 | &[data-entering] { 55 | animation: fade 200ms; 56 | } 57 | 58 | &[data-exiting] { 59 | animation: fade 150ms reverse ease-in; 60 | } 61 | } 62 | 63 | .react-aria-Modal { 64 | box-shadow: 0 8px 20px rgba(0 0 0 / 0.1); 65 | border-radius: 6px; 66 | background: white; 67 | border: 1px solid var(--spectrum-global-color-gray-300); 68 | outline: none; 69 | padding: 30px; 70 | width: 90vw; 71 | 72 | &[data-entering] { 73 | animation: zoom 300ms cubic-bezier(0.175, 0.885, 0.32, 1.275); 74 | } 75 | } 76 | 77 | @keyframes fade { 78 | from { 79 | opacity: 0; 80 | } 81 | 82 | to { 83 | opacity: 1; 84 | } 85 | } 86 | 87 | @keyframes zoom { 88 | from { 89 | transform: scale(0.8); 90 | } 91 | 92 | to { 93 | transform: scale(1); 94 | } 95 | } 96 | 97 | .react-aria-Dialog { 98 | outline: none; 99 | 100 | .react-aria-Heading { 101 | line-height: 1em; 102 | margin-top: 0; 103 | } 104 | 105 | .react-aria-Button { 106 | margin-top: 20px; 107 | } 108 | } 109 | 110 | .react-aria-Button { 111 | background: var(--spectrum-global-color-gray-50); 112 | border: 1px solid var(--spectrum-global-color-gray-400); 113 | border-radius: 4px; 114 | color: var(--spectrum-alias-text-color); 115 | appearance: none; 116 | vertical-align: middle; 117 | font-size: 1rem; 118 | text-align: center; 119 | margin: 0; 120 | outline: none; 121 | padding: 6px 10px; 122 | transition: border-color 200ms; 123 | 124 | &[data-hovered] { 125 | border-color: var(--spectrum-global-color-gray-500); 126 | } 127 | 128 | &[data-pressed] { 129 | box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.1); 130 | background: var(--spectrum-global-color-gray-100); 131 | border-color: var(--spectrum-global-color-gray-600); 132 | } 133 | 134 | &[data-focus-visible] { 135 | border-color: slateblue; 136 | box-shadow: 0 0 0 1px slateblue; 137 | } 138 | } 139 | 140 | .react-aria-TextField { 141 | margin-bottom: 8px; 142 | 143 | .react-aria-Label { 144 | display: inline-block; 145 | width: 5.357rem; 146 | } 147 | 148 | .react-aria-Input { 149 | font-size: 16px; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /app-todos/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle generating the HTTP Response for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/docs/en/main/file-conventions/entry.server 5 | */ 6 | 7 | import { PassThrough } from "node:stream"; 8 | 9 | import type { EntryContext } from "@remix-run/node"; 10 | import { createReadableStreamFromReadable } from "@remix-run/node"; 11 | import { RemixServer } from "@remix-run/react"; 12 | import isbot from "isbot"; 13 | import { renderToPipeableStream } from "react-dom/server"; 14 | 15 | const ABORT_DELAY = 5_000; 16 | 17 | export default function handleRequest( 18 | request: Request, 19 | responseStatusCode: number, 20 | responseHeaders: Headers, 21 | remixContext: EntryContext, 22 | ) { 23 | return isbot(request.headers.get("user-agent")) 24 | ? handleBotRequest( 25 | request, 26 | responseStatusCode, 27 | responseHeaders, 28 | remixContext, 29 | ) 30 | : handleBrowserRequest( 31 | request, 32 | responseStatusCode, 33 | responseHeaders, 34 | remixContext, 35 | ); 36 | } 37 | 38 | function handleBotRequest( 39 | request: Request, 40 | responseStatusCode: number, 41 | responseHeaders: Headers, 42 | remixContext: EntryContext, 43 | ) { 44 | return new Promise((resolve, reject) => { 45 | const { abort, pipe } = renderToPipeableStream( 46 | , 51 | { 52 | onAllReady() { 53 | const body = new PassThrough(); 54 | 55 | responseHeaders.set("Content-Type", "text/html"); 56 | 57 | resolve( 58 | new Response(createReadableStreamFromReadable(body), { 59 | headers: responseHeaders, 60 | status: responseStatusCode, 61 | }), 62 | ); 63 | 64 | pipe(body); 65 | }, 66 | onShellError(error: unknown) { 67 | reject(error); 68 | }, 69 | onError(error: unknown) { 70 | responseStatusCode = 500; 71 | console.error(error); 72 | }, 73 | }, 74 | ); 75 | 76 | setTimeout(abort, ABORT_DELAY); 77 | }); 78 | } 79 | 80 | function handleBrowserRequest( 81 | request: Request, 82 | responseStatusCode: number, 83 | responseHeaders: Headers, 84 | remixContext: EntryContext, 85 | ) { 86 | return new Promise((resolve, reject) => { 87 | const { abort, pipe } = renderToPipeableStream( 88 | , 93 | { 94 | onShellReady() { 95 | const body = new PassThrough(); 96 | 97 | responseHeaders.set("Content-Type", "text/html"); 98 | 99 | resolve( 100 | new Response(createReadableStreamFromReadable(body), { 101 | headers: responseHeaders, 102 | status: responseStatusCode, 103 | }), 104 | ); 105 | 106 | pipe(body); 107 | }, 108 | onShellError(error: unknown) { 109 | reject(error); 110 | }, 111 | onError(error: unknown) { 112 | console.error(error); 113 | responseStatusCode = 500; 114 | }, 115 | }, 116 | ); 117 | 118 | setTimeout(abort, ABORT_DELAY); 119 | }); 120 | } 121 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import cors from "cors" 2 | import path from "path" 3 | import express from "express" 4 | import { WebSocketServer } from "ws" 5 | import { fileURLToPath } from "url" 6 | import fs from "fs-extra" 7 | import { setupWSConnection, getYDoc } from "situated" 8 | import listen from "../machines/server" 9 | import { mapResultSet } from "./map-sqlite-resultset" 10 | import Parser from "node-sql-parser" 11 | import { serverConfig } from "./mutators" 12 | import { createClient } from "@libsql/client" 13 | import { adapter } from "trpc-yjs/adapter" 14 | import { appRouter } from "./trpc" 15 | 16 | const __filename = fileURLToPath(import.meta.url) 17 | const __dirname = path.dirname(__filename) 18 | 19 | const baseDir = 20 | process.env.BASE_DATA_DIR || path.resolve(process.cwd(), `.cache`) 21 | const file = path.join(baseDir, `db.json`) 22 | const dbsDir = path.join(baseDir, `dbs`) 23 | fs.ensureDirSync(path.dirname(file)) 24 | fs.ensureDirSync(dbsDir) 25 | 26 | const app = express() 27 | app.use(express.json()) 28 | app.use( 29 | cors({ 30 | origin: `http://localhost:5174`, 31 | credentials: true, 32 | }) 33 | ) 34 | 35 | // Serve static assets. 36 | app.use(`/`, express.static(path.join(__dirname, `./dist`))) 37 | 38 | // handle every other route with index.html, which will contain 39 | // a script tag to your application's JavaScript file(s). 40 | app.get(`*`, function (request, response) { 41 | response.sendFile(path.resolve(__dirname, `../dist/index.html`)) 42 | }) 43 | 44 | app.post(`/invalidate/:dbName`, async (req, res) => { 45 | const dbName = req.params.dbName 46 | 47 | const doc = getYDoc(`app-doc`) 48 | const dbs = doc.getMap(`dbs`) 49 | 50 | if (dbs.has(dbName)) { 51 | const ydocDb = dbs.get(dbName) 52 | // Query for total + completed and set. 53 | const db = createClient({ url: ydocDb.url, authToken: ydocDb.authToken }) 54 | const totals = mapResultSet( 55 | await db.execute( 56 | `select completed, count(*) as count from todo group by completed` 57 | ) 58 | ) 59 | doc.transact(() => { 60 | ydocDb.total = totals.map((row) => row.count).reduce((a, b) => a + b, 0) 61 | ydocDb.completed = totals.find((row) => row.completed === 1)?.count || 0 62 | ydocDb.updatedAt = new Date().toJSON() 63 | dbs.set(dbName, ydocDb) 64 | }) 65 | res.send(`ok`) 66 | } 67 | }) 68 | 69 | const wsServer = new WebSocketServer({ noServer: true }) 70 | wsServer.on(`connection`, setupWSConnection) 71 | 72 | const port = 3000 73 | 74 | const server = app.listen(port, async () => { 75 | console.log(`API listening on port ${port}`) 76 | const doc = getYDoc(`app-doc`) 77 | console.log(`got doc`) 78 | // const { context, mutators } = serverConfig({ 79 | // adminUrl: process.env.TURSO_URL, 80 | // adminAuthToken: process.env.TURSO_ADMIN_DB_AUTH_TOKEN, 81 | // }) 82 | 83 | // console.log({ db: context.adminDb }) 84 | 85 | const adminDb = createClient({ 86 | url: process.env.TURSO_URL, 87 | authToken: process.env.TURSO_ADMIN_DB_AUTH_TOKEN, 88 | }) 89 | const dbsResult = mapResultSet(await adminDb.execute(`select * from dbs`)) 90 | 91 | const dbs = doc.getMap(`dbs`) 92 | dbsResult.map((db) => { 93 | const yjsDb = dbs.has(db.name) ? dbs.get(db.name) : {} 94 | const combined = { ...db, ...yjsDb } 95 | dbs.set(combined.name, combined) 96 | }) 97 | 98 | // Start adapter 99 | console.log(`starting the adapter`) 100 | adapter({ 101 | doc, 102 | appRouter, 103 | context: { doc, adminDb }, 104 | onError: (e) => console.log(`error`, e), 105 | }) 106 | 107 | // listen.listen({ 108 | // doc, 109 | // serverConfig: { context, mutators }, 110 | // }) 111 | }) 112 | 113 | server.on(`upgrade`, (request, socket, head) => { 114 | wsServer.handleUpgrade(request, socket, head, (socket) => { 115 | wsServer.emit(`connection`, socket, request) 116 | }) 117 | }) 118 | -------------------------------------------------------------------------------- /app-admin/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app-todos/.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - dev 8 | pull_request: 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | permissions: 15 | actions: write 16 | contents: read 17 | 18 | jobs: 19 | lint: 20 | name: ⬣ ESLint 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: ⬇️ Checkout repo 24 | uses: actions/checkout@v4 25 | 26 | - name: ⎔ Setup node 27 | uses: actions/setup-node@v3 28 | with: 29 | cache: npm 30 | cache-dependency-path: ./package.json 31 | node-version: 18 32 | 33 | - name: 📥 Install deps 34 | run: npm install 35 | 36 | - name: 🔬 Lint 37 | run: npm run lint 38 | 39 | typecheck: 40 | name: ʦ TypeScript 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: ⬇️ Checkout repo 44 | uses: actions/checkout@v4 45 | 46 | - name: ⎔ Setup node 47 | uses: actions/setup-node@v3 48 | with: 49 | cache: npm 50 | cache-dependency-path: ./package.json 51 | node-version: 18 52 | 53 | - name: 📥 Install deps 54 | run: npm install 55 | 56 | - name: 🔎 Type check 57 | run: npm run typecheck --if-present 58 | 59 | vitest: 60 | name: ⚡ Vitest 61 | runs-on: ubuntu-latest 62 | steps: 63 | - name: ⬇️ Checkout repo 64 | uses: actions/checkout@v4 65 | 66 | - name: ⎔ Setup node 67 | uses: actions/setup-node@v3 68 | with: 69 | cache: npm 70 | cache-dependency-path: ./package.json 71 | node-version: 18 72 | 73 | - name: 📥 Install deps 74 | run: npm install 75 | 76 | - name: ⚡ Run vitest 77 | run: npm run test -- --coverage 78 | 79 | cypress: 80 | name: ⚫️ Cypress 81 | runs-on: ubuntu-latest 82 | steps: 83 | - name: ⬇️ Checkout repo 84 | uses: actions/checkout@v4 85 | 86 | - name: 🏄 Copy test env vars 87 | run: cp .env.example .env 88 | 89 | - name: ⎔ Setup node 90 | uses: actions/setup-node@v3 91 | with: 92 | cache: npm 93 | cache-dependency-path: ./package.json 94 | node-version: 18 95 | 96 | - name: 📥 Install deps 97 | run: npm install 98 | 99 | - name: 🛠 Setup Database 100 | run: npx prisma migrate reset --force 101 | 102 | - name: ⚙️ Build 103 | run: npm run build 104 | 105 | - name: 🌳 Cypress run 106 | uses: cypress-io/github-action@v6 107 | with: 108 | start: npm run start:mocks 109 | wait-on: http://localhost:8811 110 | env: 111 | PORT: 8811 112 | 113 | deploy: 114 | name: 🚀 Deploy 115 | runs-on: ubuntu-latest 116 | needs: [lint, typecheck, vitest, cypress] 117 | # only deploy main/dev branch on pushes 118 | if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} 119 | 120 | steps: 121 | - name: ⬇️ Checkout repo 122 | uses: actions/checkout@v4 123 | 124 | - name: 👀 Read app name 125 | uses: SebRollen/toml-action@v1.0.2 126 | id: app_name 127 | with: 128 | file: fly.toml 129 | field: app 130 | 131 | - name: 🎈 Setup Fly 132 | uses: superfly/flyctl-actions/setup-flyctl@v1.4 133 | 134 | - name: 🚀 Deploy Staging 135 | if: ${{ github.ref == 'refs/heads/dev' }} 136 | run: flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} --app ${{ steps.app_name.outputs.value }}-staging 137 | env: 138 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 139 | 140 | - name: 🚀 Deploy Production 141 | if: ${{ github.ref == 'refs/heads/main' }} 142 | run: flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} --app ${{ steps.app_name.outputs.value }} 143 | env: 144 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 145 | -------------------------------------------------------------------------------- /app-todos/app/routes/todos.$todosId.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from "@remix-run/node"; 2 | import { json } from "@remix-run/node"; 3 | import { 4 | Form, 5 | Link, 6 | NavLink, 7 | Outlet, 8 | useParams, 9 | useLoaderData, 10 | useNavigation, 11 | } from "@remix-run/react"; 12 | import { useRef, useEffect } from "react"; 13 | import { getDb, syncAdminDb } from "~/db.server"; 14 | import { mapResultSet } from "../map-sqlite-resultset"; 15 | 16 | export const loader = async ({ params, request }: LoaderFunctionArgs) => { 17 | // This will only sync the first time. 18 | await syncAdminDb(params.todosId) 19 | 20 | const db = getDb(params.todosId); 21 | const todos: Todo[] = mapResultSet(await db.execute(`select * from todo`)); 22 | console.log({ todos }); 23 | return json({ 24 | todos: todos.map((todo) => { 25 | return { ...todo, completed: todo.completed === 1 }; 26 | }), 27 | }); 28 | }; 29 | 30 | const urlBase = process.env.NODE_ENV === `production` ? `https://admin-todos-saas.fly.dev/` : `http://localhost:3000/` 31 | 32 | export let action: V2_ActionFunction = async ({ params, request }) => { 33 | const dbName = params.todosId; 34 | const db = getDb(params.todosId); 35 | if (request.method === "POST") { 36 | const data = new URLSearchParams(await request.text()); 37 | const title = data.get("title") ?? ""; 38 | const res = await db.execute({ 39 | sql: `INSERT INTO Todo (title, completed) VALUES (?, 0)`, 40 | args: [title], 41 | }); 42 | console.log({ res }); 43 | await fetch(`${urlBase}invalidate/${params.todosId}`, { 44 | method: `post`, 45 | }); 46 | return json(res, { 47 | status: 201, 48 | }); 49 | } 50 | if (request.method === "PUT") { 51 | const data = new URLSearchParams(await request.text()); 52 | const todoId = data.get("completed"); 53 | console.log(todoId); 54 | if (!todoId) 55 | return json( 56 | { error: "Todo id must be defined" }, 57 | { 58 | status: 400, 59 | } 60 | ); 61 | const todo = mapResultSet(await db.execute({ 62 | sql: `SELECT * from Todo where id = ?`, 63 | args: [todoId], 64 | }))[0] 65 | console.log(todo); 66 | if (!todo) { 67 | return json( 68 | { error: "Todo does not exist" }, 69 | { 70 | status: 400, 71 | } 72 | ); 73 | } 74 | await db.execute({ 75 | sql: `UPDATE TODO set completed=? where id = ?`, 76 | args: [todo.completed === 1 ? 0 : 1, todoId], 77 | }); 78 | 79 | 80 | await fetch(`${urlBase}invalidate/${params.todosId}`, { 81 | method: `post`, 82 | }); 83 | return json(`ok`, { status: 200 }); 84 | } 85 | if (request.method === "DELETE") { 86 | const data = new URLSearchParams(await request.text()); 87 | const todoId = data.get("delete"); 88 | console.log(todoId); 89 | if (!todoId) 90 | return json( 91 | { error: "Todo id must be defined" }, 92 | { 93 | status: 400, 94 | } 95 | ); 96 | await db.execute({ sql: `DELETE from TODO where id = ?`, args: [todoId] }); 97 | await fetch(`${urlBase}invalidate/${params.todosId}`, { 98 | method: `post`, 99 | }); 100 | return json(`ok`, { status: 200 }); 101 | } 102 | 103 | return null; 104 | }; 105 | 106 | type LoaderData = { 107 | todos: Todo[]; 108 | }; 109 | 110 | export default function Index() { 111 | let data = useLoaderData(); 112 | let params = useParams(); 113 | let formRef = useRef(null); 114 | const transition = useNavigation(); 115 | console.log({ data, formRef, transition }); 116 | 117 | // data.todos = [] 118 | 119 | useEffect(() => { 120 | if (transition.state === "loading") { 121 | formRef.current?.reset(); 122 | } 123 | }, [transition.state]); 124 | 125 | return ( 126 |
127 |

{params.todosId} todos

128 |
    129 | {data.todos.map((todo) => ( 130 |
  • 131 |
    139 |

    145 | {todo.title} 146 |

    147 |
    148 |
    149 | 150 | 151 |
    152 |
    153 | 154 | 155 |
    156 |
    157 |
    158 |
  • 159 | ))} 160 |
161 |
162 | 167 | 177 |
178 |
179 | ); 180 | } 181 | -------------------------------------------------------------------------------- /server/mutators.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, afterAll, describe, expect, test } from "vitest" 2 | import * as Y from "yjs" 3 | import fs from "fs" 4 | import path from "path" 5 | import { listen } from "../machines/server" 6 | import { createRequest } from "../machines/client" 7 | import { serverConfig } from "./mutators" 8 | import * as util from "node:util" 9 | import * as child_process from "node:child_process" 10 | 11 | const execAsync = util.promisify(child_process.exec) 12 | 13 | let tmpDir: string = `` 14 | let doc 15 | let adminDb 16 | beforeAll(async () => { 17 | const dirName = 18 | `test-` + Date.now() + `-` + Math.random().toString(36).substring(2, 7) 19 | tmpDir = path.join(`/tmp`, dirName) 20 | 21 | try { 22 | // Create the directory synchronously 23 | fs.mkdirSync(tmpDir) 24 | } catch (err) { 25 | console.error(`Failed to create directory:`, err) 26 | } 27 | 28 | doc = new Y.Doc() 29 | 30 | const adminDbCredentials = { 31 | url: `libsql://todos-saas-admin-kyleamathews.turso.io`, 32 | authToken: `eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2OTU2Nzg1MjksImlkIjoiM2JjODcxMDgtNWJlZC0xMWVlLTkwOWItNTIxZDZkZmM4M2VmIn0.ARL4cjEOTRD3J-aQET9nOILCgNr9TE6diAV-Rxv-eWWl0P1cmIuuPVxoBFH32pGJKyM03yL3H-Tnq52VXpQwDA`, 33 | } 34 | 35 | const { context, mutators } = serverConfig({ 36 | adminUrl: adminDbCredentials.url, 37 | adminAuthToken: adminDbCredentials.authToken, 38 | }) 39 | console.log({ mutators }) 40 | 41 | adminDb = context.adminDb 42 | // await adminDb.execute(`CREATE TABLE dbs ( 43 | // url TEXT PRIMARY KEY NOT NULL, 44 | // authToken TEXT NOT NULL, 45 | // state TEXT NOT NULL, 46 | // name TEXT NOT NULL, 47 | // updatedAt TEXT NOT NULL 48 | // ); 49 | // `) 50 | listen({ 51 | doc, 52 | serverConfig: { mutators }, 53 | }) 54 | }) 55 | 56 | // Cleanup 57 | afterAll(async () => { 58 | let hasDb = true 59 | try { 60 | await execAsync(`turso db list | grep foo`) 61 | } catch (e) { 62 | console.log(e) 63 | hasDb = false 64 | } 65 | if (hasDb) { 66 | const destroyOutput = await execAsync(`turso db destroy foo --yes`) 67 | console.log({ destroyOutput }) 68 | } 69 | 70 | fs.rmSync(tmpDir, { recursive: true }) 71 | }) 72 | 73 | test(`update a robot name`, async () => { 74 | const doc = new Y.Doc() 75 | 76 | const robots = doc.getMap(`robots`) 77 | const robotId = `123` 78 | robots.set(robotId, { id: robotId, name: `boop` }) 79 | 80 | // Config that gets new requests & calls right function. 81 | const serverConfig = { 82 | mutators: { 83 | updateRobotName: async function ({ state, doc }) { 84 | await new Promise((resolve) => setTimeout(resolve, 3)) 85 | // Async work first and then return func w/ any sync changes. 86 | return function () { 87 | const robots = doc.getMap(`robots`) 88 | const robot = robots.get(state.request.id) 89 | robot.name = state.request.name 90 | robots.set(state.request.id, robot) 91 | return { ok: true } 92 | } 93 | }, 94 | }, 95 | } 96 | 97 | listen({ doc, serverConfig }) 98 | 99 | let newName = `beep` 100 | const requestObject = await createRequest({ 101 | doc, 102 | mutator: `updateRobotName`, 103 | request: { id: robotId, name: newName }, 104 | }) 105 | 106 | const { id, clientCreate, serverResponded, ...toSnapshot } = requestObject 107 | expect(toSnapshot).toMatchSnapshot() 108 | expect(requestObject.done).toBeTruthy() 109 | expect(robots.get(requestObject.request.id).name).toEqual(newName) 110 | 111 | newName = `boop` 112 | const requestObject2 = await createRequest({ 113 | doc, 114 | mutator: `updateRobotName`, 115 | request: { id: robotId, name: newName }, 116 | }) 117 | 118 | expect(robots.get(requestObject2.request.id).name).toEqual(newName) 119 | }) 120 | 121 | function makeid(length) { 122 | let result = `` 123 | const characters = `abcdefghijklmnopqrstuvwxyz0123456789` 124 | const charactersLength = characters.length 125 | let counter = 0 126 | while (counter < length) { 127 | result += characters.charAt(Math.floor(Math.random() * charactersLength)) 128 | counter += 1 129 | } 130 | return result 131 | } 132 | describe(`serverConfig`, () => { 133 | const dbName = `foo-${makeid(3)}` 134 | const cloneDbName = `${dbName}-clone` 135 | test(`createDb`, async () => { 136 | const requestObject = await createRequest({ 137 | doc, 138 | mutator: `createDb`, 139 | request: { name: dbName }, 140 | }) 141 | 142 | console.log({ requestObject }) 143 | expect(requestObject.response.name).toEqual(dbName) 144 | 145 | expect(Object.keys(requestObject.response)).toMatchSnapshot() 146 | expect(Object.keys(doc.getMap(`dbs`).get(dbName))).toMatchSnapshot() 147 | expect(doc.getMap(`dbs`).get(dbName).completed).toEqual(0) 148 | expect(doc.getMap(`dbs`).get(dbName).total).toEqual(1) 149 | 150 | const result = await adminDb.execute({ 151 | sql: `SELECT * from dbs where name=:name`, 152 | args: { name: dbName }, 153 | }) 154 | expect(result.rows.length).toEqual(1) 155 | expect(result.rows[0][3]).toEqual(dbName) 156 | }, 10000) 157 | test(`clone`, async () => { 158 | const requestObject = await createRequest({ 159 | doc, 160 | mutator: `createDb`, 161 | request: { name: cloneDbName, fromDb: dbName }, 162 | }) 163 | 164 | console.log({ requestObject }) 165 | expect(requestObject.response.name).toEqual(cloneDbName) 166 | 167 | expect(Object.keys(requestObject.response)).toMatchSnapshot() 168 | expect(Object.keys(doc.getMap(`dbs`).get(cloneDbName))).toMatchSnapshot() 169 | 170 | const result = await adminDb.execute({ 171 | sql: `SELECT * from dbs where name=:name`, 172 | args: { name: dbName }, 173 | }) 174 | expect(result.rows.length).toEqual(1) 175 | expect(result.rows[0][3]).toEqual(dbName) 176 | }, 10000) 177 | test(`deleteDb`, async () => { 178 | const requestObject = await createRequest({ 179 | doc, 180 | mutator: `deleteDb`, 181 | request: { name: dbName }, 182 | }) 183 | const requestObject2 = await createRequest({ 184 | doc, 185 | mutator: `deleteDb`, 186 | request: { name: cloneDbName }, 187 | }) 188 | console.log({ 189 | requestObject: requestObject.response, 190 | requestObject2: requestObject2.response, 191 | }) 192 | 193 | expect(requestObject.response.name).toEqual(dbName) 194 | expect(Object.keys(requestObject.response)).toMatchSnapshot() 195 | expect(fs.existsSync(requestObject.response.dbPath)).toBeFalsy() 196 | 197 | const result = await adminDb.execute({ 198 | sql: `SELECT * from dbs where name=:name`, 199 | args: { name: dbName }, 200 | }) 201 | expect(result.rows.length).toEqual(0) 202 | }) 203 | }) 204 | -------------------------------------------------------------------------------- /app-admin/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import { 3 | Button, 4 | Dialog, 5 | DialogTrigger, 6 | Heading, 7 | TextField, 8 | Label, 9 | Modal, 10 | TextArea, 11 | } from "react-aria-components" 12 | import { useYjs, useSubscribeYjs, useAwarenessStates } from "situated" 13 | import "./App.css" 14 | import { format } from "timeago.js" 15 | import { createTRPCProxyClient, loggerLink } from "@trpc/client" 16 | import { link as yjsLink } from "trpc-yjs/link" 17 | import { AppRouter } from "../../server/trpc" 18 | 19 | const trpc = createTRPCProxyClient({ 20 | links: [ 21 | loggerLink(), 22 | yjsLink({ 23 | doc: window.rootDoc, 24 | }), 25 | ], 26 | }) 27 | 28 | window.trpc = trpc 29 | 30 | function makeid(length) { 31 | let result = "" 32 | const characters = "abcdefghijklmnopqrstuvwxyz0123456789" 33 | const charactersLength = characters.length 34 | let counter = 0 35 | while (counter < length) { 36 | result += characters.charAt(Math.floor(Math.random() * charactersLength)) 37 | counter += 1 38 | } 39 | return result 40 | } 41 | 42 | const appServerBase = 43 | process.env.NODE_ENV === `production` 44 | ? `https://app-server-todos-saas.fly.dev/todos/` 45 | : `http://localhost:10000/todos/` 46 | 47 | function SelectModal({ dbName, requests }) { 48 | const selects = requests 49 | .filter((request) => { 50 | return ( 51 | request.path == `selectDb` && 52 | request.input.name === dbName && 53 | request.error !== true 54 | ) 55 | }) 56 | .reverse() 57 | .slice(0, 5) 58 | 59 | return ( 60 | 61 | 69 | 70 | 71 | {({ close }) => ( 72 |
{ 74 | e.preventDefault() 75 | const sql = e.target[0].value 76 | const res = await trpc.selectDb.query({ 77 | name: dbName, 78 | sql, 79 | }) 80 | console.log({ res }) 81 | }} 82 | > 83 | Run Query in {dbName} 84 | 85 | 86 |