├── app ├── vite-env.d.ts ├── rand.ts ├── date.ts ├── Status.tsx ├── main.tsx ├── test-data.ts ├── index.css └── App.tsx ├── public └── favicon.ico ├── tsconfig.json ├── shared ├── context.ts ├── must.ts ├── queries.ts ├── mutators.ts └── schema.ts ├── .env ├── README.md ├── index.html ├── .gitignore ├── server ├── index.ts ├── query.ts ├── login.ts └── mutate.ts ├── tsconfig.node.json ├── tsconfig.app.json ├── docker ├── docker-compose.yml └── seed.sql ├── eslint.config.js ├── sst-env.d.ts ├── vite.config.ts ├── package.json ├── sst.config.ts ├── CODE_OF_CONDUCT.md └── LICENSE /app/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rocicorp/hello-zero-solid/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /shared/context.ts: -------------------------------------------------------------------------------- 1 | export type Context = 2 | | { 3 | userID: string; 4 | } 5 | | undefined; 6 | 7 | declare module "@rocicorp/zero" { 8 | interface DefaultTypes { 9 | context: Context; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /shared/must.ts: -------------------------------------------------------------------------------- 1 | export function must(v: T | undefined | null, msg?: string): T { 2 | // eslint-disable-next-line eqeqeq 3 | if (v == null) { 4 | throw new Error(msg ?? `Unexpected ${v} value`); 5 | } 6 | return v; 7 | } 8 | -------------------------------------------------------------------------------- /app/rand.ts: -------------------------------------------------------------------------------- 1 | export const randBetween = (min: number, max: number) => 2 | Math.floor(Math.random() * (max - min) + min); 3 | export const randInt = (max: number) => randBetween(0, max); 4 | export const randID = () => Math.random().toString(36).slice(2); 5 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | AUTH_SECRET="secretkey" 2 | ZERO_UPSTREAM_DB="postgresql://user:password@127.0.0.1/postgres" 3 | ZERO_MUTATE_FORWARD_COOKIES="true" 4 | ZERO_MUTATE_URL="http://localhost:5173/api/mutate" 5 | ZERO_QUERY_FORWARD_COOKIES="true" 6 | ZERO_QUERY_URL="http://localhost:5173/api/query" 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zero + Solid + TypeScript + Vite 2 | 3 | ```bash 4 | git clone https://github.com/rocicorp/hello-zero-solid.git 5 | cd hello-zero-solid 6 | npm install 7 | npm run dev:db-up 8 | 9 | # in a second terminal 10 | npm run dev:zero-cache 11 | 12 | # in yet another terminal 13 | npm run dev:ui 14 | ``` 15 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | When?? 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.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 | *.localtsbuildinfo 14 | *.tsbuildinfo 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | # zero 28 | zero.db* 29 | 30 | # sst 31 | .sst 32 | -------------------------------------------------------------------------------- /app/date.ts: -------------------------------------------------------------------------------- 1 | // The built-in date formatter is surprisingly slow in Chrome. 2 | export const formatDate = (timestamp: number) => { 3 | const date = new Date(timestamp); 4 | const year = date.getFullYear(); 5 | const month = (date.getMonth() + 1).toString().padStart(2, "0"); 6 | const day = date.getDate().toString().padStart(2, "0"); 7 | const hours = date.getHours().toString().padStart(2, "0"); 8 | const minutes = date.getMinutes().toString().padStart(2, "0"); 9 | return `${year}-${month}-${day} ${hours}:${minutes}`; 10 | }; 11 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import { Hono } from "hono"; 3 | import { handle } from "hono/vercel"; 4 | import { handleLogin } from "./login"; 5 | import { handleMutate } from "./mutate"; 6 | import { handleQuery } from "./query"; 7 | 8 | export const app = new Hono().basePath("/api"); 9 | 10 | app.get("/login", (c) => handleLogin(c)); 11 | 12 | app.post("/mutate", async (c) => { 13 | return await c.json(await handleMutate(c)); 14 | }); 15 | 16 | app.post("/query", async (c) => { 17 | return await c.json(await handleQuery(c)); 18 | }); 19 | 20 | export default handle(app); 21 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["vite.config.ts", "server", "shared"] 22 | } 23 | -------------------------------------------------------------------------------- /server/query.ts: -------------------------------------------------------------------------------- 1 | import { handleQueryRequest } from "@rocicorp/zero/server"; 2 | import { mustGetQuery } from "@rocicorp/zero"; 3 | import { queries } from "../shared/queries"; 4 | import { schema } from "../shared/schema"; 5 | import { getUserID } from "./login"; 6 | import { Context } from "hono"; 7 | 8 | export async function handleQuery(c: Context) { 9 | const userID = await getUserID(c); 10 | const ctx = userID ? { userID } : undefined; 11 | return handleQueryRequest( 12 | (name, args) => { 13 | const query = mustGetQuery(queries, name); 14 | return query.fn({ args, ctx }); 15 | }, 16 | schema, 17 | c.req.raw 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | "jsx": "preserve", 16 | "jsxImportSource": "solid-js", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "exactOptionalPropertyTypes": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true 24 | }, 25 | "include": ["app", "shared"] 26 | } 27 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | zstart_postgres: 3 | image: postgres:16.2-alpine 4 | shm_size: 1g 5 | user: postgres 6 | restart: always 7 | healthcheck: 8 | test: 'pg_isready -U user --dbname=postgres' 9 | interval: 10s 10 | timeout: 5s 11 | retries: 5 12 | ports: 13 | - 5432:5432 14 | environment: 15 | POSTGRES_USER: user 16 | POSTGRES_DB: postgres 17 | POSTGRES_PASSWORD: password 18 | command: | 19 | postgres 20 | -c wal_level=logical 21 | -c max_wal_senders=10 22 | -c max_replication_slots=5 23 | -c hot_standby=on 24 | -c hot_standby_feedback=on 25 | volumes: 26 | - zstart_solid_pgdata:/var/lib/postgresql/data 27 | - ./:/docker-entrypoint-initdb.d 28 | 29 | volumes: 30 | zstart_solid_pgdata: 31 | driver: local 32 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /app/Status.tsx: -------------------------------------------------------------------------------- 1 | import { useConnectionState } from "@rocicorp/zero/solid"; 2 | 3 | export function Status() { 4 | const state = useConnectionState(); 5 | 6 | return ( 7 | <> 8 | {(() => { 9 | const s = state(); 10 | switch (s.name) { 11 | case "connecting": 12 | return
🔄 Connecting...
; 13 | case "connected": 14 | return
✅ Connected
; 15 | case "disconnected": 16 | return
🔴 Offline
; 17 | case "error": 18 | return
❌ Error
; 19 | case "needs-auth": 20 | return
🔐 Session expired
; 21 | default: 22 | throw new Error(`Unexpected connection state: ${s.name}`); 23 | } 24 | })()} 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /app/main.tsx: -------------------------------------------------------------------------------- 1 | /* @refresh reload */ 2 | import { render } from "solid-js/web"; 3 | import App from "./App.tsx"; 4 | import "./index.css"; 5 | import { schema } from "../shared/schema.ts"; 6 | import Cookies from "js-cookie"; 7 | import { ZeroProvider } from "@rocicorp/zero/solid"; 8 | import { mutators } from "../shared/mutators.ts"; 9 | 10 | const signedCookie = Cookies.get("auth"); 11 | const userID = signedCookie ? signedCookie.split(".")[0] : "anon"; 12 | const context = signedCookie ? { userID } : undefined; 13 | 14 | const root = document.getElementById("root"); 15 | 16 | render( 17 | () => ( 18 | 27 | 28 | 29 | ), 30 | root! 31 | ); 32 | -------------------------------------------------------------------------------- /sst-env.d.ts: -------------------------------------------------------------------------------- 1 | /* This file is auto-generated by SST. Do not edit. */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | /* deno-fmt-ignore-file */ 5 | 6 | declare module "sst" { 7 | export interface Resource { 8 | PostgresConnectionString: { 9 | type: "sst.sst.Secret"; 10 | value: string; 11 | }; 12 | ZeroAuthSecret: { 13 | type: "sst.sst.Secret"; 14 | value: string; 15 | }; 16 | "replication-bucket": { 17 | name: string; 18 | type: "sst.aws.Bucket"; 19 | }; 20 | "replication-manager": { 21 | service: string; 22 | type: "sst.aws.Service"; 23 | url: string; 24 | }; 25 | "view-syncer": { 26 | service: string; 27 | type: "sst.aws.Service"; 28 | url: string; 29 | }; 30 | vpc: { 31 | type: "sst.aws.Vpc"; 32 | }; 33 | } 34 | } 35 | /// 36 | 37 | import "sst"; 38 | export {}; 39 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import solid from "vite-plugin-solid"; 3 | import { getRequestListener } from "@hono/node-server"; 4 | import { app } from "./server/index.js"; 5 | 6 | export default defineConfig({ 7 | optimizeDeps: { 8 | esbuildOptions: { 9 | supported: { 10 | "top-level-await": true, 11 | }, 12 | }, 13 | }, 14 | esbuild: { 15 | supported: { 16 | "top-level-await": true, 17 | }, 18 | }, 19 | plugins: [ 20 | solid(), 21 | { 22 | name: "server", 23 | configureServer(server) { 24 | server.middlewares.use((req, res, next) => { 25 | if (!req.url?.startsWith("/api")) { 26 | return next(); 27 | } 28 | getRequestListener(async (request) => { 29 | return await app.fetch(request, {}); 30 | })(req, res); 31 | }); 32 | }, 33 | }, 34 | ], 35 | }); 36 | -------------------------------------------------------------------------------- /server/login.ts: -------------------------------------------------------------------------------- 1 | import { randomInt } from "crypto"; 2 | import { getSignedCookie, setSignedCookie } from "hono/cookie"; 3 | import { Context } from "hono"; 4 | import { must } from "../shared/must"; 5 | 6 | // See seed.sql 7 | // In real life you would of course authenticate the user however you like. 8 | const userIDs = [ 9 | "6z7dkeVLNm", 10 | "ycD76wW4R2", 11 | "IoQSaxeVO5", 12 | "WndZWmGkO4", 13 | "ENzoNm7g4E", 14 | "dLKecN3ntd", 15 | "7VoEoJWEwn", 16 | "enVvyDlBul", 17 | "9ogaDuDNFx", 18 | ]; 19 | 20 | const secretKey = must(process.env.AUTH_SECRET, "required env var AUTH_SECRET"); 21 | 22 | export async function handleLogin(c: Context) { 23 | // Pick a random user ID from the userIDs array 24 | const userID = userIDs[randomInt(userIDs.length)]; 25 | await setSignedCookie(c, "auth", userID, secretKey); 26 | return c.text("ok"); 27 | } 28 | 29 | export async function getUserID(c: Context) { 30 | const cookie = await getSignedCookie(c, secretKey, "auth"); 31 | if (!cookie) { 32 | return undefined; 33 | } 34 | return cookie; 35 | } 36 | -------------------------------------------------------------------------------- /server/mutate.ts: -------------------------------------------------------------------------------- 1 | import postgres from "postgres"; 2 | import { zeroPostgresJS } from "@rocicorp/zero/server/adapters/postgresjs"; 3 | import { must } from "../shared/must"; 4 | import { schema } from "../shared/schema"; 5 | import { Context } from "hono"; 6 | import { getUserID } from "./login"; 7 | import { handleMutateRequest } from "@rocicorp/zero/server"; 8 | import { mustGetMutator } from "@rocicorp/zero"; 9 | import { mutators } from "../shared/mutators"; 10 | 11 | const dbProvider = zeroPostgresJS( 12 | schema, 13 | postgres( 14 | must( 15 | process.env.ZERO_UPSTREAM_DB as string, 16 | "required env var ZERO_UPSTREAM_DB" 17 | ) 18 | ) 19 | ); 20 | 21 | export async function handleMutate(c: Context) { 22 | const userID = await getUserID(c); 23 | const ctx = userID ? { userID } : undefined; 24 | return handleMutateRequest( 25 | dbProvider, 26 | (transact) => { 27 | return transact((tx, name, args) => { 28 | const mutator = mustGetMutator(mutators, name); 29 | return mutator.fn({ tx, args, ctx }); 30 | }); 31 | }, 32 | c.req.raw 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /app/test-data.ts: -------------------------------------------------------------------------------- 1 | import { randBetween, randID, randInt } from "./rand"; 2 | import { Medium, Message, User } from "../shared/schema"; 3 | 4 | const requests = [ 5 | "Hey guys, is the zero package ready yet?", 6 | "I tried installing the package, but it's not there.", 7 | "The package does not install...", 8 | "Hey, can you ask Aaron when the npm package will be ready?", 9 | "npm npm npm npm npm", 10 | "n --- p --- m", 11 | "npm wen", 12 | "npm package?", 13 | ]; 14 | 15 | const replies = [ 16 | "It will be ready next week", 17 | "We'll let you know", 18 | "It's not ready - next week", 19 | "next week i think", 20 | "Didn't we say next week", 21 | "I could send you a tarball, but it won't work", 22 | ]; 23 | 24 | export function randomMessage( 25 | users: readonly User[], 26 | mediums: readonly Medium[] 27 | ): Message { 28 | const id = randID(); 29 | const mediumID = mediums[randInt(mediums.length)].id; 30 | const timestamp = randBetween(1727395200000, new Date().getTime()); 31 | const isRequest = randInt(10) <= 6; 32 | const messages = isRequest ? requests : replies; 33 | const senders = users.filter((u) => u.partner === !isRequest); 34 | const senderID = senders[randInt(senders.length)].id; 35 | return { 36 | id, 37 | senderID, 38 | mediumID, 39 | body: messages[randInt(messages.length)], 40 | timestamp, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /shared/queries.ts: -------------------------------------------------------------------------------- 1 | import { escapeLike, defineQueries, defineQuery } from "@rocicorp/zero"; 2 | import z from "zod"; 3 | import { zql } from "./schema"; 4 | 5 | export const queries = defineQueries({ 6 | user: { 7 | all: defineQuery(() => zql.user), 8 | }, 9 | medium: { 10 | all: defineQuery(() => zql.medium), 11 | }, 12 | message: { 13 | all: defineQuery(() => zql.message.orderBy("timestamp", "desc")), 14 | filtered: defineQuery( 15 | z.object({ 16 | senderID: z.string(), 17 | mediumID: z.string(), 18 | body: z.string(), 19 | timestamp: z.string(), 20 | }), 21 | ({ args: { senderID, mediumID, body, timestamp } }) => { 22 | let q = zql.message 23 | .related("medium", (q) => q.one()) 24 | .related("sender", (q) => q.one()) 25 | .orderBy("timestamp", "desc"); 26 | 27 | if (senderID) { 28 | q = q.where("senderID", senderID); 29 | } 30 | if (mediumID) { 31 | q = q.where("mediumID", mediumID); 32 | } 33 | if (body) { 34 | q = q.where("body", "LIKE", `%${escapeLike(body)}%`); 35 | } 36 | if (timestamp) { 37 | q = q.where( 38 | "timestamp", 39 | ">=", 40 | timestamp ? new Date(timestamp).getTime() : 0 41 | ); 42 | } 43 | 44 | return q; 45 | } 46 | ), 47 | }, 48 | }); 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hello-zero-solid", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "build": "tsc -b && vite build", 8 | "dev:clean": "source .env && docker volume rm -f docker_zstart_solid_pgdata && rm -rf \"${ZERO_REPLICA_FILE}\"*", 9 | "dev:db-down": "docker compose --env-file .env -f ./docker/docker-compose.yml down", 10 | "dev:db-up": "docker compose --env-file .env -f ./docker/docker-compose.yml up", 11 | "dev:ui": "VITE_PUBLIC_ZERO_CACHE_URL='http://localhost:4848' vite", 12 | "dev:zero-cache": "zero-cache-dev", 13 | "lint": "eslint ." 14 | }, 15 | "dependencies": { 16 | "@rocicorp/zero": "0.25.2", 17 | "js-cookie": "^3.0.5", 18 | "postgres": "^3.4.5", 19 | "solid-js": "^1.9.3", 20 | "sst": "3.9.26", 21 | "zod": "^3.25.76" 22 | }, 23 | "devDependencies": { 24 | "@eslint/js": "^9.9.0", 25 | "@hono/node-server": "^1.13.2", 26 | "@types/aws-lambda": "8.10.147", 27 | "@types/js-cookie": "^3.0.6", 28 | "@types/node": "^22.7.9", 29 | "dotenv": "^16.4.5", 30 | "eslint": "^9.9.0", 31 | "globals": "^15.9.0", 32 | "hono": "^4.6.6", 33 | "typescript": "^5.5.3", 34 | "typescript-eslint": "^8.0.1", 35 | "vite": "^5.4.1", 36 | "vite-plugin-solid": "^2.10.2" 37 | }, 38 | "trustedDependencies": [ 39 | "@rocicorp/zero-sqlite3" 40 | ], 41 | "pnpm": { 42 | "onlyBuiltDependencies": [ 43 | "@rocicorp/zero-sqlite3" 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /docker/seed.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "user" ( 2 | "id" VARCHAR PRIMARY KEY, 3 | "name" VARCHAR NOT NULL, 4 | "partner" BOOLEAN NOT NULL 5 | ); 6 | 7 | CREATE TABLE "medium" ( 8 | "id" VARCHAR PRIMARY KEY, 9 | "name" VARCHAR NOT NULL 10 | ); 11 | 12 | CREATE TABLE "message" ( 13 | "id" VARCHAR PRIMARY KEY, 14 | "sender_id" VARCHAR REFERENCES "user"(id), 15 | "medium_id" VARCHAR REFERENCES "medium"(id), 16 | "body" VARCHAR NOT NULL, 17 | "timestamp" TIMESTAMP not null 18 | ); 19 | 20 | INSERT INTO "user" (id, name, partner) VALUES ('ycD76wW4R2', 'Aaron', true); 21 | INSERT INTO "user" (id, name, partner) VALUES ('IoQSaxeVO5', 'Matt', true); 22 | INSERT INTO "user" (id, name, partner) VALUES ('WndZWmGkO4', 'Cesar', true); 23 | INSERT INTO "user" (id, name, partner) VALUES ('ENzoNm7g4E', 'Erik', true); 24 | INSERT INTO "user" (id, name, partner) VALUES ('dLKecN3ntd', 'Greg', true); 25 | INSERT INTO "user" (id, name, partner) VALUES ('enVvyDlBul', 'Darick', true); 26 | INSERT INTO "user" (id, name, partner) VALUES ('9ogaDuDNFx', 'Alex', true); 27 | INSERT INTO "user" (id, name, partner) VALUES ('6z7dkeVLNm', 'Dax', false); 28 | INSERT INTO "user" (id, name, partner) VALUES ('7VoEoJWEwn', 'Nate', false); 29 | 30 | INSERT INTO "medium" (id, name) VALUES ('G14bSFuNDq', 'Discord'); 31 | INSERT INTO "medium" (id, name) VALUES ('b7rqt_8w_H', 'Twitter DM'); 32 | INSERT INTO "medium" (id, name) VALUES ('0HzSMcee_H', 'Tweet reply to unrelated thread'); 33 | INSERT INTO "medium" (id, name) VALUES ('ttx7NCmyac', 'SMS'); 34 | -------------------------------------------------------------------------------- /shared/mutators.ts: -------------------------------------------------------------------------------- 1 | import { defineMutator, defineMutators } from "@rocicorp/zero"; 2 | import { must } from "./must"; 3 | import z from "zod"; 4 | import { Context } from "./context"; 5 | import { zql } from "./schema"; 6 | 7 | export const mutators = defineMutators({ 8 | message: { 9 | create: defineMutator( 10 | z.object({ 11 | id: z.string(), 12 | mediumID: z.string(), 13 | senderID: z.string(), 14 | body: z.string(), 15 | timestamp: z.number(), 16 | }), 17 | async ({ tx, args }) => { 18 | await tx.mutate.message.insert(args); 19 | } 20 | ), 21 | delete: defineMutator( 22 | z.object({ 23 | id: z.string(), 24 | }), 25 | async ({ tx, args: { id }, ctx }) => { 26 | mustBeLoggedIn(ctx); 27 | await tx.mutate.message.delete({ id }); 28 | } 29 | ), 30 | update: defineMutator( 31 | z.object({ 32 | message: z.object({ 33 | id: z.string(), 34 | body: z.string(), 35 | }), 36 | }), 37 | async ({ tx, args: { message }, ctx }) => { 38 | mustBeLoggedIn(ctx); 39 | const prev = await tx.run(zql.message.where("id", message.id).one()); 40 | if (!prev) { 41 | return; 42 | } 43 | if (prev.senderID !== ctx.userID) { 44 | throw new Error("Must be sender of message to edit"); 45 | } 46 | await tx.mutate.message.update(message); 47 | } 48 | ), 49 | }, 50 | }); 51 | 52 | function mustBeLoggedIn(ctx: Context): asserts ctx { 53 | must(ctx, "Must be logged in"); 54 | } 55 | -------------------------------------------------------------------------------- /app/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 | font-size: 0.9em; 6 | 7 | color-scheme: light dark; 8 | color: rgba(255, 255, 255, 0.87); 9 | background-color: black; 10 | 11 | font-synthesis: none; 12 | text-rendering: optimizeLegibility; 13 | -webkit-font-smoothing: antialiased; 14 | -moz-osx-font-smoothing: grayscale; 15 | } 16 | 17 | body { 18 | margin: 0; 19 | display: flex; 20 | place-items: center; 21 | flex-direction: column; 22 | min-width: 320px; 23 | min-height: 100vh; 24 | } 25 | 26 | #root { 27 | margin: 0 auto; 28 | padding: 2rem; 29 | text-align: center; 30 | width: 90%; 31 | min-width: 600px; 32 | max-width: 1280px; 33 | } 34 | 35 | a { 36 | font-weight: 500; 37 | } 38 | 39 | a:hover { 40 | color: #535bf2; 41 | } 42 | 43 | button { 44 | border-radius: 8px; 45 | border: 1px solid transparent; 46 | padding: 0.6em 1.2em; 47 | font-weight: 500; 48 | font-family: inherit; 49 | cursor: pointer; 50 | transition: border-color 0.25s; 51 | } 52 | button:hover { 53 | border-color: #646cff; 54 | } 55 | button:focus, 56 | button:focus-visible { 57 | outline: 4px auto -webkit-focus-ring-color; 58 | } 59 | 60 | table.messages { 61 | width: 100%; 62 | border-spacing: 0px; 63 | border-collapse: separate; 64 | } 65 | 66 | table.messages td { 67 | white-space: nowrap; 68 | overflow: none; 69 | text-overflow: ellipsis; 70 | } 71 | 72 | table.messages td, 73 | table.messages th { 74 | text-align: left; 75 | border: 1px solid grey; 76 | padding: 6px; 77 | } 78 | 79 | table.messages td:last-child { 80 | cursor: pointer; 81 | } 82 | 83 | .controls { 84 | display: flex; 85 | gap: 1rem; 86 | align-items: center; 87 | margin-bottom: 1rem; 88 | white-space: nowrap; 89 | } 90 | 91 | .controls > div { 92 | flex: 1; 93 | display: flex; 94 | flex-direction: row; 95 | justify-content: start; 96 | gap: 1rem; 97 | align-items: center; 98 | margin-bottom: 1rem; 99 | } 100 | -------------------------------------------------------------------------------- /shared/schema.ts: -------------------------------------------------------------------------------- 1 | // These data structures define your client-side schema. 2 | // They must be equal to or a subset of the server-side schema. 3 | // Note the "relationships" field, which defines first-class 4 | // relationships between tables. 5 | // See https://github.com/rocicorp/mono/blob/main/apps/zbugs/src/domain/schema.ts 6 | // for more complex examples, including many-to-many. 7 | 8 | import { 9 | createSchema, 10 | Row, 11 | table, 12 | string, 13 | boolean, 14 | relationships, 15 | UpdateValue, 16 | number, 17 | createBuilder, 18 | } from "@rocicorp/zero"; 19 | 20 | const user = table("user") 21 | .columns({ 22 | id: string(), 23 | name: string(), 24 | partner: boolean(), 25 | }) 26 | .primaryKey("id"); 27 | 28 | const medium = table("medium") 29 | .columns({ 30 | id: string(), 31 | name: string(), 32 | }) 33 | .primaryKey("id"); 34 | 35 | const message = table("message") 36 | .columns({ 37 | id: string(), 38 | senderID: string().from("sender_id"), 39 | mediumID: string().from("medium_id"), 40 | body: string(), 41 | timestamp: number(), 42 | }) 43 | .primaryKey("id"); 44 | 45 | const messageRelationships = relationships(message, ({ one }) => ({ 46 | sender: one({ 47 | sourceField: ["senderID"], 48 | destField: ["id"], 49 | destSchema: user, 50 | }), 51 | medium: one({ 52 | sourceField: ["mediumID"], 53 | destField: ["id"], 54 | destSchema: medium, 55 | }), 56 | })); 57 | 58 | export const schema = createSchema({ 59 | tables: [user, medium, message], 60 | relationships: [messageRelationships], 61 | enableLegacyMutators: false, 62 | enableLegacyQueries: false, 63 | }); 64 | 65 | export type Schema = typeof schema; 66 | export type Message = Row; 67 | export type MessageUpdate = UpdateValue; 68 | export type Medium = Row; 69 | export type User = Row; 70 | 71 | export const zql = createBuilder(schema); 72 | 73 | declare module "@rocicorp/zero" { 74 | interface DefaultTypes { 75 | schema: typeof schema; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /sst.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /// 3 | import { execSync } from "child_process"; 4 | 5 | export default $config({ 6 | app(input) { 7 | return { 8 | name: "hello-zero-solid", 9 | removal: input?.stage === "production" ? "retain" : "remove", 10 | home: "aws", 11 | region: process.env.AWS_REGION || "us-east-1", 12 | }; 13 | }, 14 | async run() { 15 | const zeroVersion = execSync( 16 | "npm list @rocicorp/zero | grep @rocicorp/zero | cut -f 3 -d @" 17 | ) 18 | .toString() 19 | .trim(); 20 | 21 | // S3 Bucket 22 | const replicationBucket = new sst.aws.Bucket(`replication-bucket`); 23 | 24 | // VPC Configuration 25 | const vpc = new sst.aws.Vpc(`vpc`, { 26 | az: 2, 27 | nat: "ec2", 28 | }); 29 | 30 | // ECS Cluster 31 | const cluster = new sst.aws.Cluster(`cluster`, { 32 | vpc, 33 | }); 34 | 35 | const conn = new sst.Secret("PostgresConnectionString"); 36 | const zeroAuthSecret = new sst.Secret("ZeroAuthSecret"); 37 | 38 | // Common environment variables 39 | const commonEnv = { 40 | ZERO_UPSTREAM_DB: conn.value, 41 | ZERO_AUTH_SECRET: zeroAuthSecret.value, 42 | ZERO_REPLICA_FILE: "sync-replica.db", 43 | ZERO_LITESTREAM_BACKUP_URL: $interpolate`s3://${replicationBucket.name}/backup`, 44 | ZERO_IMAGE_URL: `rocicorp/zero:${zeroVersion}`, 45 | ZERO_CVR_MAX_CONNS: "10", 46 | ZERO_UPSTREAM_MAX_CONNS: "10", 47 | }; 48 | 49 | // Replication Manager Service 50 | const replicationManager = new sst.aws.Service(`replication-manager`, { 51 | cluster, 52 | cpu: "0.5 vCPU", 53 | memory: "1 GB", 54 | architecture: "arm64", 55 | image: commonEnv.ZERO_IMAGE_URL, 56 | link: [replicationBucket], 57 | health: { 58 | command: ["CMD-SHELL", "curl -f http://localhost:4849/ || exit 1"], 59 | interval: "5 seconds", 60 | retries: 3, 61 | startPeriod: "300 seconds", 62 | }, 63 | environment: { 64 | ...commonEnv, 65 | ZERO_CHANGE_MAX_CONNS: "3", 66 | ZERO_NUM_SYNC_WORKERS: "0", 67 | }, 68 | loadBalancer: { 69 | public: false, 70 | ports: [ 71 | { 72 | listen: "80/http", 73 | forward: "4849/http", 74 | }, 75 | ], 76 | }, 77 | transform: { 78 | loadBalancer: { 79 | idleTimeout: 3600, 80 | }, 81 | target: { 82 | healthCheck: { 83 | enabled: true, 84 | path: "/keepalive", 85 | protocol: "HTTP", 86 | interval: 5, 87 | healthyThreshold: 2, 88 | timeout: 3, 89 | }, 90 | }, 91 | }, 92 | }); 93 | 94 | // View Syncer Service 95 | const viewSyncer = new sst.aws.Service(`view-syncer`, { 96 | cluster, 97 | cpu: "1 vCPU", 98 | memory: "2 GB", 99 | architecture: "arm64", 100 | image: commonEnv.ZERO_IMAGE_URL, 101 | link: [replicationBucket], 102 | health: { 103 | command: ["CMD-SHELL", "curl -f http://localhost:4848/ || exit 1"], 104 | interval: "5 seconds", 105 | retries: 3, 106 | startPeriod: "300 seconds", 107 | }, 108 | environment: { 109 | ...commonEnv, 110 | ZERO_CHANGE_STREAMER_URI: replicationManager.url, 111 | }, 112 | logging: { 113 | retention: "1 month", 114 | }, 115 | loadBalancer: { 116 | public: true, 117 | rules: [{ listen: "80/http", forward: "4848/http" }], 118 | }, 119 | transform: { 120 | target: { 121 | healthCheck: { 122 | enabled: true, 123 | path: "/keepalive", 124 | protocol: "HTTP", 125 | interval: 5, 126 | healthyThreshold: 2, 127 | timeout: 3, 128 | }, 129 | stickiness: { 130 | enabled: true, 131 | type: "lb_cookie", 132 | cookieDuration: 120, 133 | }, 134 | loadBalancingAlgorithmType: "least_outstanding_requests", 135 | }, 136 | }, 137 | }); 138 | }, 139 | }); 140 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | hello@roci.dev. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /app/App.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery, useZero } from "@rocicorp/zero/solid"; 2 | import Cookies from "js-cookie"; 3 | import { createEffect, createSignal, For, Show } from "solid-js"; 4 | import { formatDate } from "./date"; 5 | import { randInt } from "./rand"; 6 | import { randomMessage } from "./test-data"; 7 | import { queries } from "../shared/queries"; 8 | import { mutators } from "../shared/mutators"; 9 | import { Status } from "./Status"; 10 | 11 | function App() { 12 | const zero = useZero(); 13 | 14 | const [users] = useQuery(queries.user.all); 15 | const [mediums] = useQuery(queries.medium.all); 16 | 17 | const [filterUser, setFilterUser] = createSignal(""); 18 | const [filterMedium, setFilterMedium] = createSignal(""); 19 | const [filterText, setFilterText] = createSignal(""); 20 | const [filterDate, setFilterDate] = createSignal(""); 21 | 22 | const [allMessages] = useQuery(queries.message.all); 23 | 24 | const [filteredMessages] = useQuery(() => 25 | queries.message.filtered({ 26 | senderID: filterUser(), 27 | mediumID: filterMedium(), 28 | body: filterText(), 29 | timestamp: filterDate(), 30 | }) 31 | ); 32 | 33 | const hasFilters = () => 34 | filterUser() || filterMedium() || filterText() || filterDate(); 35 | const [action, setAction] = createSignal<"add" | "remove" | undefined>( 36 | undefined 37 | ); 38 | 39 | createEffect(() => { 40 | if (action() !== undefined) { 41 | const interval = setInterval(() => { 42 | if (!handleAction()) { 43 | clearInterval(interval); 44 | setAction(undefined); 45 | } 46 | }, 1000 / 60); 47 | } 48 | }); 49 | 50 | const handleAction = () => { 51 | if (action() === undefined) { 52 | return false; 53 | } 54 | if (action() === "add") { 55 | zero().mutate(mutators.message.create(randomMessage(users(), mediums()))); 56 | return true; 57 | } else { 58 | const messages = allMessages(); 59 | if (messages.length === 0) { 60 | return false; 61 | } 62 | const index = randInt(messages.length); 63 | zero().mutate(mutators.message.delete({ id: messages[index].id })); 64 | return true; 65 | } 66 | }; 67 | 68 | const addMessages = () => setAction("add"); 69 | 70 | const removeMessages = (e: MouseEvent) => { 71 | if (zero().userID === "anon" && !e.shiftKey) { 72 | alert( 73 | "You must be logged in to delete. Hold the shift key to try anyway." 74 | ); 75 | return; 76 | } 77 | setAction("remove"); 78 | }; 79 | 80 | const stopAction = () => setAction(undefined); 81 | 82 | const editMessage = ( 83 | e: MouseEvent, 84 | id: string, 85 | senderID: string, 86 | prev: string 87 | ) => { 88 | if (senderID !== zero().userID && !e.shiftKey) { 89 | alert( 90 | "You aren't logged in as the sender of this message. Editing won't be permitted. Hold the shift key to try anyway." 91 | ); 92 | return; 93 | } 94 | const body = prompt("Edit message", prev); 95 | zero().mutate( 96 | mutators.message.update({ 97 | message: { 98 | id, 99 | body: body ?? prev, 100 | }, 101 | }) 102 | ); 103 | }; 104 | 105 | const toggleLogin = async () => { 106 | if (zero().userID === "anon") { 107 | await fetch("/api/login"); 108 | } else { 109 | Cookies.remove("auth"); 110 | } 111 | location.reload(); 112 | }; 113 | 114 | // If initial sync hasn't completed, these can be empty. 115 | const initialSyncComplete = () => users().length && mediums().length; 116 | 117 | const user = () => 118 | users().find((user) => user.id === zero().userID)?.name ?? "anon"; 119 | 120 | return ( 121 | 122 |
123 |
124 | 127 | 130 | (hold buttons to repeat) 131 |
132 |
137 | {user() === "anon" ? "" : `Logged in as ${user()}`} 138 | 141 | 142 |
143 |
144 |
145 |
146 | From: 147 | 156 |
157 |
158 | By: 159 | 169 |
170 |
171 | Contains: 172 | setFilterText(e.target.value)} 176 | style={{ flex: 1 }} 177 | /> 178 |
179 |
180 | After: 181 | setFilterDate(e.target.value)} 184 | style={{ flex: 1 }} 185 | /> 186 |
187 |
188 |
189 | 190 | {!hasFilters() ? ( 191 | <>Showing all {filteredMessages().length} messages 192 | ) : ( 193 | <> 194 | Showing {filteredMessages().length} of {allMessages().length}{" "} 195 | messages. Try opening{" "} 196 | 197 | another tab 198 | {" "} 199 | to see them all! 200 | 201 | )} 202 | 203 |
204 | {filteredMessages().length === 0 ? ( 205 |

206 | No posts found 😢 207 |

208 | ) : ( 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | {(message) => ( 222 | 223 | 224 | 225 | 226 | 227 | 234 | 235 | )} 236 | 237 | 238 |
SenderMediumMessageSentEdit
{message.sender?.name}{message.medium?.name}{message.body}{formatDate(message.timestamp)} 229 | editMessage(e, message.id, message.senderID, message.body) 230 | } 231 | > 232 | ✏️ 233 |
239 | )} 240 |
241 | ); 242 | } 243 | 244 | export default App; 245 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------