diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 1efe6943..59a5f2b1 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -37,11 +37,11 @@ export default defineConfig({ slug: "getting-started/starting-up", }, { - label: "First UI+TS App", + label: "First App", slug: "getting-started/first-ui-app", }, { - label: "First CLI App", + label: "A CRUD App", slug: "getting-started/first-cli-app", }, { diff --git a/docs/src/content/docs/getting-started/first-cli-app.mdx b/docs/src/content/docs/getting-started/first-cli-app.mdx index f1051758..47e8f8a4 100644 --- a/docs/src/content/docs/getting-started/first-cli-app.mdx +++ b/docs/src/content/docs/getting-started/first-cli-app.mdx @@ -1,5 +1,5 @@ --- -title: First CLI App +title: A CRUD App --- import { Code } from "@astrojs/starlight/components"; diff --git a/docs/src/content/docs/getting-started/first-ui-app.mdx b/docs/src/content/docs/getting-started/first-ui-app.mdx index b293a0a8..86dfc448 100644 --- a/docs/src/content/docs/getting-started/first-ui-app.mdx +++ b/docs/src/content/docs/getting-started/first-ui-app.mdx @@ -1,8 +1,9 @@ --- -title: First UI + TypeScript App +title: > + First App: UI & Custom Endpoints --- -import screenshot from "../../../../../examples/coffeesearch/assets/screenshot.png"; +import { Code } from "@astrojs/starlight/components"; In this tutorial, we'll set up a database with coffee data, implement a custom handler for vector search in TypeScript, and a simple web UI all in ~100 lines @@ -77,71 +78,41 @@ $ cat import.sql | sqlite3 traildepot/data/main.db - ``` Now with the initial import, let's start TrailBase for the first time. This -will will apply the following migration under -`/examples/coffeesearch/traildepot/migrations`, which is basically: +will apply the migrations under `/examples/coffeesearch/traildepot/migrations`, +basically: ```sql -UPDATE coffee SET embedding = VECTOR(FORMAT("[%f, %f, %f, %f]", Aroma, Flavor, Acidity, Sweetness)) +UPDATE coffee SET embedding = VECTOR(FORMAT("[%f, %f, %f, %f]", Aroma, Flavor, Acidity, Sweetness)); ``` -to initialize the vector embeddings from our different coffee notes. So we run: +late-initializing `coffee.embedding`s for all records. So we run: ```bash -$ trail run --dev +$ trail run ``` -If the server comes up successfully you've done everything correctly. +If the server comes up successfully, you've done everything right. # A JS/TS Vector Search Handler -Now we need a custom API endpoint our UI can call later to look up with coffee -most closely resembles our requested combination of notes. -For that we place the following definition into -`/examples/coffeesearch/traildepot/scripts/index.ts`: +Let's have a quick look at the custom TS/JS HTTP endpoint defined in +`/examples/coffeesearch/traildepot/scripts/main.ts` that our UI will use to +find the coffee that most closely matches our desired notes: -```ts -import { - addRoute, - jsonHandler, - parsePath, - query -} from "../trailbase.js"; +import handlerCode from "../../../../../examples/coffeesearch/traildepot/scripts/main.ts?raw"; -addRoute( - "GET", - "/search", - jsonHandler(async (req) => { - const searchParams = parsePath(req.uri).query; + - const aroma = searchParams?.get("aroma") ?? 8; - const flavor = searchParams?.get("flavor") ?? 8; - const acidity = searchParams?.get("acidity") ?? 8; - const sweetness = searchParams?.get("sweetness") ?? 8; +The above script installs an `get('/search')` HTTP endpoint that reads notes +from the query parameters and looks up coffees in the database ordered by +vector distance, i.e. how well they match. - return await query(` - SELECT - Owner, - Aroma, - Flavor, - Acidity, - Sweetness, - vector_distance_cos( - embedding, - '[${aroma}, ${flavor}, ${acidity}, ${sweetness}]' - ) AS distance - FROM - coffee - WHERE - embedding IS NOT NULL AND distance < 0.2 - ORDER BY - distance - LIMIT 100`, []); - }), -); -``` - -This custom handler will let us query the coffees that most closely resemble -our desired combination of notes, e.g.: +This is a public API. While `trail` is up, we can simply test it by running: ```bash $ curl "http://localhost:4000/search?aroma=8&flavor=8&acidity=8&sweetness=8" @@ -152,134 +123,86 @@ $ curl "http://localhost:4000/search?aroma=8&flavor=8&acidity=8&sweetness=8" ] ``` -If we're only interested in the API, we're basically done. Otherwise, the -following section will implement a simple React web App. +We're done with the server side. This is already enough to build a simple UI. +With a few simple commands we've ingested CSV data and built a custom HTTP +endpoint using vector search. +If you're not interested in a UI, the same approach setup could also be used to +identify relevant documents for AI applications. ## A simple Web UI -We recommend [vite](https://vite.dev/guide/) for setting up a simple SPA, e.g.: +After setting up our database, vector search and APIs, we should probably use +them for good measure. We could build a mobile app, have an LLM answer coffee +prompts, or to keep it simple: build a small web UI. +Moreover, a web UI also lets us touch more generally on bundling and deploying +web applications with TrailBase. + +The specifics of the UI are not the focus of this tutorial that's why we +chose React as the most well-known option and kept the implementation to less +than 80 lines of code. In case you want to build your own, we recommend +[vite](https://vite.dev/guide/) to quickly set up an SPA with your favorite JS +framework, e.g.: `pnpm create vite@latest my-project -- --template react`. + +Our reference implementation, renders 4 numeric input fields to search for +coffee with a given aroma, flavor, acidity and sweetness score: + +import uiCode from "../../../../../examples/coffeesearch/src/App.tsx?raw"; + + + +We can run the UI in a vite dev-server by running: ```bash -$ pnpm create vite@latest my-project -- --template react +pnpm install && pnpm dev ``` -We can then swap out the template with `my-project/src/App.tsx`: +## Deployment: Putting Everything Together -```tsx -import { useState, useEffect } from "react"; -import "./App.css"; - -type Data = Array>; - -async function fetchData(v: { - aroma: number; - flavor: number; - acidity: number; - sweetness: number; -}): Promise { - const URL = import.meta.env.DEV ? "http://localhost:4000" : ""; - const params = Object.entries(v).map(([k, v]) => `${k}=${v}`).join("&"); - const response = await fetch(`${URL}/search?${params}`); - return await response.json(); -} - -const Input = (props: { - label: string; - value: number; - update: (v: number) => void; -}) => ( - <> - - props.update(el.target.valueAsNumber)} - /> - -); - -const Row = (props: { row: Array }) => ( - - {props.row.map((d) => ( - {`${d}`} - ))} - -); - -function Table() { - const [aroma, setAroma] = useState(8); - const [flavor, setFlavor] = useState(8); - const [acidity, setAcidity] = useState(8); - const [sweetness, setSweetness] = useState(8); - - const [data, setData] = useState(); - useEffect(() => { - setData(undefined); - fetchData({ aroma, flavor, acidity, sweetness }).then(setData); - }, [aroma, flavor, acidity, sweetness]); - - return ( - <> -
- - - - -
- -
- - - - - - - - - - - - - {(data ?? []).map((row) => ( - - ))} - -
OwnerAromaFlavorAciditySweetness
-
- - ); -} - -export const App = () => ( - <> -

Coffee Search

- - -); -``` - -Lastly we need to compile our `JSX/TSX` down into pure HTML, JS, and CSS our -browser can understand. Running +Whether you've followed along or skipped to here, we can now put everything +together. +Let's start by compiling our `JSX/TSX` web UI down to pure HTML, JS, and CSS +artifacts the browser can understand by running: ```bash -pnpm build +pnpm install && pnpm build ``` -the resulting built artifacts can be found under `my-project/dist/`. - -### Putting Everything Together - -Whether you've followed along or skipped to here, you can now start TrailBase: - -* Pointing out our prepared database -* Providing our custom `/search` endpoint -* And serving our web artifacts in `dist/` (if you haven't built the website yet - run `pnpm build`) +These artifacts, found in `./dist`, can then be served alongside our data and +custom API simply by running: ```bash -trail run --public-dir /dist +trail run --public-dir dist ``` -You can now browse to [new custom UI](http://localhost:4000/) check out the -[admin dashboard](http://localhost:4000/_/admin). +You can now check out your fuly self-contained app under +[http://localhost:4000/](http://localhost:4000/) or browse the coffee data in +the [admin dashboard](http://localhost:4000/_/admin). + +All we need to serve our application in production is[^1]: + +- the static `trail` binary, +- the `traildepot` folder containing the data and endpoints, +- the `dist` folder containing our web app. + +At the end of the day it's just a bunch of hermetic files without transitively +depending on a pyramid of shared libraries or other services including +databases. +This makes it very easy to just copy the files over to your server or bundle +everything in a single container. +`examples/coffeesearch/Dockerfile` is an example of how you can both build and +bundle using Docker. + +
+ +---- + +[^1]: + To serve HTTPS you'll either need a reverse proxy in front to terminate TLS + or if you don't require end-to-end encryption (e.g. you're not using auth + or handling sensitive data) you can fall back to TLS termination via a CDN + like cloudflare. diff --git a/examples/coffeesearch/assets/screenshot.png b/examples/coffeesearch/assets/screenshot.png index b51deb3d..1adf0f68 100644 Binary files a/examples/coffeesearch/assets/screenshot.png and b/examples/coffeesearch/assets/screenshot.png differ diff --git a/examples/coffeesearch/src/App.tsx b/examples/coffeesearch/src/App.tsx index 83aa1dc3..8cb9c8bf 100644 --- a/examples/coffeesearch/src/App.tsx +++ b/examples/coffeesearch/src/App.tsx @@ -1,22 +1,6 @@ import { useState, useEffect } from "react"; import "./App.css"; -type Data = Array>; - -async function fetchData(v: { - aroma: number; - flavor: number; - acidity: number; - sweetness: number; -}): Promise { - const URL = import.meta.env.DEV ? "http://localhost:4000" : ""; - const params = Object.entries(v) - .map(([k, v]) => `${k}=${v}`) - .join("&"); - const response = await fetch(`${URL}/search?${params}`); - return await response.json(); -} - const Input = (props: { label: string; value: number; @@ -26,31 +10,29 @@ const Input = (props: { props.update(el.target.valueAsNumber)} + onChange={(e) => props.update(e.target.valueAsNumber)} /> ); -const Row = (props: { row: Array }) => ( -
- {props.row.map((d) => ( - - ))} - -); - function Table() { const [aroma, setAroma] = useState(8); const [flavor, setFlavor] = useState(8); const [acidity, setAcidity] = useState(8); const [sweetness, setSweetness] = useState(8); - const [data, setData] = useState(); + const [data, setData] = useState> | undefined>(); useEffect(() => { - setData(undefined); - fetchData({ aroma, flavor, acidity, sweetness }).then(setData); + const URL = import.meta.env.DEV ? "http://localhost:4000" : ""; + const params = Object.entries({ aroma, flavor, acidity, sweetness }) + .map(([k, v]) => `${k}=${v}`) + .join("&"); + + fetch(`${URL}/search?${params}`).then(async (r) => setData(await r.json())); }, [aroma, flavor, acidity, sweetness]); return ( @@ -66,17 +48,21 @@ function Table() {
{`${typeof d === "number" ? (d as number).toPrecision(3) : d}`}
- - - - - + + + + + {(data ?? []).map((row) => ( - + + {row.map((d) => ( + + ))} + ))}
OwnerAromaFlavorAciditySweetnessOwnerAromaFlavorAciditySweetness
{d.toString()}
diff --git a/examples/coffeesearch/traildepot/scripts/index.ts b/examples/coffeesearch/traildepot/scripts/index.ts deleted file mode 100644 index 372974da..00000000 --- a/examples/coffeesearch/traildepot/scripts/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { addRoute, jsonHandler, parsePath, query } from "../trailbase.js"; -import type { JsonRequestType } from "../trailbase.js"; - -addRoute( - "GET", - "/search", - jsonHandler(async (req: JsonRequestType) => { - const searchParams = parsePath(req.uri).query; - - const aroma = searchParams?.get("aroma") ?? 8; - const flavor = searchParams?.get("flavor") ?? 8; - const acidity = searchParams?.get("acidity") ?? 8; - const sweetness = searchParams?.get("sweetness") ?? 8; - - return await query( - ` - SELECT - Owner, - Aroma, - Flavor, - Acidity, - Sweetness, - vector_distance_cos(embedding, '[${aroma}, ${flavor}, ${acidity}, ${sweetness}]') AS distance - FROM - coffee - WHERE - embedding IS NOT NULL AND distance < 0.2 - ORDER BY - distance - LIMIT 100 - `, - [], - ); - }), -); diff --git a/examples/coffeesearch/traildepot/scripts/main.ts b/examples/coffeesearch/traildepot/scripts/main.ts new file mode 100644 index 00000000..b5bf646a --- /dev/null +++ b/examples/coffeesearch/traildepot/scripts/main.ts @@ -0,0 +1,32 @@ +import { addRoute, jsonHandler, parsePath, query } from "../trailbase.js"; +import type { JsonRequestType } from "../trailbase.js"; + +addRoute( + "GET", + "/search", + jsonHandler(async (req: JsonRequestType) => { + const searchParams = parsePath(req.uri).query; + + const aroma = searchParams.get("aroma") ?? 8; + const flavor = searchParams.get("flavor") ?? 8; + const acidity = searchParams.get("acidity") ?? 8; + const sweetness = searchParams.get("sweetness") ?? 8; + + return await query( + `SELECT + Owner, + Aroma, + Flavor, + Acidity, + Sweetness + FROM + coffee + WHERE + embedding IS NOT NULL + ORDER BY + vector_distance_cos(embedding, '[${aroma}, ${flavor}, ${acidity}, ${sweetness}]') + LIMIT 100`, + [], + ); + }), +); diff --git a/examples/coffeesearch/traildepot/trailbase.d.ts b/examples/coffeesearch/traildepot/trailbase.d.ts index f6c61098..a7c705ca 100644 --- a/examples/coffeesearch/traildepot/trailbase.d.ts +++ b/examples/coffeesearch/traildepot/trailbase.d.ts @@ -125,6 +125,7 @@ export interface JsonResponseType { export declare function jsonHandler(f: (req: JsonRequestType) => MaybeResponse): CallbackType; export declare function addRoute(method: Method, route: string, callback: CallbackType): void; export declare function dispatch(method: Method, route: string, uri: string, pathParams: [string, string][], headers: [string, string][], user: UserType | undefined, body: Uint8Array): Promise; +export declare function addPeriodicCallback(milliseconds: number, cb: (cancel: () => void) => void): () => void; export declare function query(queryStr: string, params: unknown[]): Promise; export declare function execute(queryStr: string, params: unknown[]): Promise; export type ParsedPath = { diff --git a/examples/coffeesearch/traildepot/trailbase.js b/examples/coffeesearch/traildepot/trailbase.js index 58d7004c..ab42490a 100644 --- a/examples/coffeesearch/traildepot/trailbase.js +++ b/examples/coffeesearch/traildepot/trailbase.js @@ -485,10 +485,16 @@ export function jsonHandler(f) { }; } const callbacks = new Map(); +function isolateId() { + return rustyscript.functions.isolate_id(); +} export function addRoute(method, route, callback) { - rustyscript.functions.route(method, route); + const id = isolateId(); + if (id === 0) { + rustyscript.functions.install_route(method, route); + console.debug("JS: Added route:", method, route); + } callbacks.set(`${method}:${route}`, callback); - console.debug("JS: Added route:", method, route); } export async function dispatch(method, route, uri, pathParams, headers, user, body) { const key = `${method}:${route}`; @@ -505,6 +511,20 @@ export async function dispatch(method, route, uri, pathParams, headers, user, bo })) ?? { status: StatusCodes.OK }); } globalThis.__dispatch = dispatch; +export function addPeriodicCallback(milliseconds, cb) { + // Note: right now we run periodic tasks only on the first isolate. This is + // very simple but doesn't use other workers. This has nice properties in + // terms of state management and hopefully work-stealing will alleviate the + // issue, i.e. workers will pick up the slack in terms of incoming requests. + const id = isolateId(); + if (id !== 0) { + return () => { }; + } + const handle = setInterval(() => { + cb(() => clearInterval(handle)); + }, milliseconds); + return () => clearInterval(handle); +} export async function query(queryStr, params) { return await rustyscript.async_functions.query(queryStr, params); }