diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..cd937f5 --- /dev/null +++ b/.air.toml @@ -0,0 +1,16 @@ +# Air config for the Go backend. +# +# Pattern from charm.land/hyper: only watch Go source. The web side has its +# own HMR loop via `bun run dev` (Vite); CSS/JS/templates over there are +# handled by SvelteKit and never need a Go rebuild. + +root = "server" +tmp_dir = "tmp" + +[build] +cmd = "go build -o ./tmp/potluck ./cmd/server" +full_bin = "CI=1 CLICOLOR_FORCE=1 ./tmp/potluck" +args_bin = ["--auto-migrate"] +include_ext = ["go", "mod", "sum"] +exclude_dir = ["tmp", "dist", "node_modules"] +stop_on_error = true diff --git a/.gitignore b/.gitignore index 4c49bd7..c8a9916 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,32 @@ .env +.env.* +!.env.example + +# Go +server/dist/ +server/tmp/ +*.test +*.out + +# Node / Bun +node_modules/ +web/.svelte-kit/ +web/build/ +web/dist/ +web/.wrangler/ +.bun/ + +# SQLite + Litestream local +*.db +*.db-shm +*.db-wal +data/ + +# Editors & OS +.DS_Store +.idea/ +.vscode/ +*.swp + +# Task runner cache +.task/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9f3d996 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,480 @@ +# AGENTS.md — potluck + +> Conventions and context for AI coding agents working in this repo. Humans +> should also read this — it's the project's source of truth on architecture +> decisions. + +## What this is + +**potluck** is a self-hosted LLM gateway over [pioneer.ai](https://pioneer.ai) +for a small group of friends sharing access. It exposes two surfaces: + +1. **A chat UI** — local-first SvelteKit app, conversations stored + client-side in IndexedDB. +2. **An OpenAI-compatible HTTP API** — bearer-token authenticated, so users + can plug their personal API keys into Claude Code, Continue, scripts, + whatever. + +Pooled funding model: friends contribute USD against a shared ledger; the +backend enforces per-user balance and rate limits regardless of which surface +a request comes through. A single upstream provider key handles all traffic. + +Not a public product. Designed for ~10 users, single deployment, opinionated +about correctness over scale. + +## Architecture + +``` + ┌─────────────┐ ┌──────────────────┐ + │ browser │ ──HTTPS──────► │ Cloudflare │ + │ (chat UI) │ │ Worker │ static SPA + + └─────────────┘ │ (SvelteKit) │ /api proxy + └─────────┬────────┘ + │ /api/* (cookie session) + ▼ + ┌─────────────┐ ┌──────────────────┐ + │ Claude Code,│ ──HTTPS───────►│ Go backend │ auth, ledger, + │ scripts, │ /v1/* with │ (terebithia, │ spend tracking, + │ Continue... │ bearer pk_... │ NixOS) │ stream tee + └─────────────┘ └─────────┬────────┘ + │ HTTPS, OpenAI shape + ▼ + pioneer.ai +``` + +Two services, two API surfaces, one repo: + +- **`web/`** — SvelteKit SPA on Cloudflare Workers. Serves the chat UI and + the thin `/api/*` proxy to the backend. No business logic. +- **`server/`** — Go HTTP service on terebithia. Two route trees over a + shared middleware stack (auth → balance → rate limit → forward → settle): + - `/api/*` — **internal**, cookie-authenticated, used by the SvelteKit + app. Owns conversations, uses the tee + buffer + resume streaming + pattern, generations survive client disconnect. + - `/v1/*` — **public**, bearer-authenticated, OpenAI-compatible. Stateless + pass-through proxy, no buffer, no resume; client disconnect cancels + upstream (the request is no longer wanted, don't burn budget). + +The two surfaces share middleware but **not** the streaming layer. Their +semantics genuinely differ; do not try to unify them. + +## Stack + +### Backend +- Go 1.23+, stdlib `net/http` with [`chi`](https://github.com/go-chi/chi) router +- SQLite via `modernc.org/sqlite` (pure Go, easier cross-compile to aarch64) +- [Litestream](https://litestream.io) for continuous backup to Backblaze B2 +- [`sqlc`](https://sqlc.dev) for type-safe query codegen +- [`goose`](https://github.com/pressly/goose) for migrations +- `log/slog` (stdlib) for structured logging +- `golang.org/x/time/rate` for per-user rate limiting +- No ORM. No DI container. No global state outside `main`. + +### Frontend +- Svelte 5 (runes mode, no legacy reactivity) +- SvelteKit with `@sveltejs/adapter-cloudflare` +- [Dexie](https://dexie.org) for local-first conversation state in IndexedDB, + with `dexie/svelte` bindings for reactive queries +- Bun as package manager (Vite handles bundling under SvelteKit) +- Tailwind, no component library by default + +### Infra +- Cloudflare Worker (SvelteKit handles the entry; `web/wrangler.toml` configures it) +- NixOS module at `nix/module.nix` wraps the server binary as a systemd unit +- Single static-binary deploy via GitHub Actions → SSH to terebithia + +## Design system + +The full system lives in `web/src/lib/styles/tokens.css` and +`design/design-system.md`. Quick reference: + +**Palette** — six brand colors, never used directly in components. All +component CSS reads semantic tokens (`--bg-page`, `--text`, `--accent`, etc.) +which are theme-aware via the CSS `light-dark()` function. + +| Raw | Hex | Role | +|---|---|---| +| `--soft-blush` | `#ffd9da` | highlight fills, text on dark | +| `--blush-rose` | `#ea638c` | accent on dark | +| `--dark-raspberry` | `#89023e` | accent on light | +| `--jet-black` | `#30343f` | text on light, surface on dark | +| `--carbon-black` | `#1b2021` | page bg on dark | +| `--paper` | `#fffaf9` | page bg on light | + +**Accent role swap.** `--dark-raspberry` is the link/eyebrow color on light +(dark enough for contrast on cream); `--blush-rose` takes that role on dark +(raspberry is too low-contrast on carbon at small sizes). Same five colors, +swapped jobs. The `--accent` token handles this automatically. + +**Fonts** — self-hosted via `@fontsource-variable/*` (Latin subset only): + +- **Fraunces Variable** for display/headlines. Set + `font-variation-settings: var(--fraunces-display)` (`opsz 144, SOFT 50`) at + ~20px and up; `var(--fraunces-text)` (`opsz 9, SOFT 0`) below. +- **Inter Variable** for body and UI. +- **IBM Plex Mono** for code and all numeric stats. **Static 400/500 weights + only**, not the variable build — the variable version has rendering quirks + on tabular figures at UI sizes. Use `font-feature-settings: "tnum" 1` for + numeric content (already on in defaults). + +The three most critical woff2 files are ``-ed from +`+layout.svelte` to avoid FOIT. + +**Theming** — three modes (`auto`, `light`, `dark`) toggled by cycling +`` content. No `data-theme` attribute, no +`prefers-color-scheme` media queries, no flash of wrong theme (inline boot +script in `app.html` reads localStorage before paint). API lives in +`web/src/lib/theme.ts`: `currentTheme()`, `setTheme(mode)`, `cycleTheme()`. + +Cycle order: **auto → dark → light → auto**. Don't change this. + +## Repository layout + +``` +. +├── server/ Go backend +│ ├── cmd/server/ main + wiring +│ ├── internal/ +│ │ ├── api/ +│ │ │ ├── middleware/ shared: auth, balance, rate limit, spend +│ │ │ ├── web/ /api/* (cookie session, internal shape) +│ │ │ └── v1/ /v1/* (bearer auth, OpenAI shape) +│ │ ├── auth/ sessions, api keys, hashing +│ │ ├── ledger/ balance, contributions, spend +│ │ ├── stream/ SSE tee + buffer (web/ only; v1/ does pass-through) +│ │ ├── provider/ pioneer.ai client + compat checks +│ │ ├── fakeprovider/ test double for the provider +│ │ ├── money/ int64-micros helpers +│ │ └── store/ sqlc-generated queries + migrations runner +│ ├── db/ +│ │ ├── migrations/ sequential, never rewritten +│ │ └── queries/ .sql files for sqlc +│ └── Makefile +├── web/ SvelteKit SPA +│ ├── src/ +│ │ ├── lib/ +│ │ │ ├── styles/ +│ │ │ │ └── tokens.css design tokens (palette + semantic + type) +│ │ │ ├── db.ts Dexie schema + reactive queries +│ │ │ ├── stream.ts SSE consumer with resume +│ │ │ ├── theme.ts theme switcher API +│ │ │ └── api.ts /api client +│ │ ├── routes/ +│ │ │ ├── +layout.svelte font imports + preloads +│ │ │ ├── +page.svelte home +│ │ │ ├── chat/+page.svelte chat UI +│ │ │ └── api/[...path]/+server.ts proxy → backend +│ │ ├── hooks.server.ts request_id, error boundary +│ │ └── app.html meta color-scheme + FOUC boot script +│ ├── svelte.config.js +│ └── wrangler.toml +├── nix/ NixOS module + flake +├── design/ long-form architecture docs +└── AGENTS.md this file +``` + +## Build / run / test + +Task runner is [Go Task](https://taskfile.dev) (`Taskfile.yml` at the repo +root). Air watches the Go backend; Vite/Bun watches the web app. Run them +in parallel with `task watch`. Configuration is documented in +[`CONFIG.md`](./CONFIG.md), generated from `server/internal/config/config.go` +via `task generate`. + +```bash +# Dev — runs backend (air) and web (vite) in parallel +task watch # alias: task dev + +# Just the backend, no watcher +task # alias for `task server`, runs --auto-migrate + +# Tests +task test # runs test:server and test:web in parallel +task test:server # Go tests +task test:web # web unit tests + +# Build +task build # builds server and web in parallel +nix build .#server # via flake + +# Migrations +task migrate:new -- add_widgets +task migrate:up +task migrate:down + +# Codegen +task sqlc # regenerate sqlc; idempotent (sources/generates) +task generate # regenerate CONFIG.md from config struct tags + +# Deploy +task deploy:server # scp + systemctl restart on terebithia (TODO) +task deploy:web # wrangler deploy +``` + +### Config + +Configuration uses [`caarlos0/env/v11`](https://github.com/caarlos0/env) +struct tags on `config.Config`. `.env` is autoloaded +(`github.com/joho/godotenv/autoload`) so local dev needs no shell ceremony — +copy `.env.sample` and edit. `config.MustGet()` panics on missing required +values so a misconfigured boot fails loud. + +Adding a new env var means: add a field with `env:` and `envDefault:` tags, +re-run `task generate` to refresh `CONFIG.md`, copy a new commented line +into `.env.sample`. Don't read `os.Getenv` outside the config package. + +## Conventions + +### Money is int64 micros, never floats +1 USD = 1,000,000 micros. Helpers in `internal/money`. A float anywhere in +the spend path is a bug. + +### Idempotency keys on every mutation +- `messages.client_id` — client-generated UUIDv7 on user message creation. + Server upserts on this. Optimistic UI relies on it. +- `streams.idempotency_key` — sent on `POST /api/chat`. Retries with the + same key return the existing stream, not a new LLM call. + +This is the single most common way LLM proxies leak money. Take it seriously. + +### Streaming +- SSE only. No WebSockets. +- The producer goroutine reading from pioneer lives in `context.Background()`, + **never** the HTTP request context of the initiating client. Client + disconnects must not cancel in-flight generations. +- Persist chunks to `stream_chunks` **before** publishing to the in-memory bus. + Durability > latency by ~5ms. +- Heartbeat `: ping\n\n` every 15s from subscriber loops to defeat + intermediate idle-killing. +- Resume protocol: client passes `?after_seq=N`; server replays chunks with + `seq > N` from DB, then attaches to the live bus. +- **This applies to `/api/*` only.** `/v1/*` is pure pass-through: upstream + is bound to request context, disconnect cancels the LLM call, no buffer, + no resume. API clients don't refresh tabs. + +### Public API (`/v1/*`) +- OpenAI-compatible request and response shape, byte-for-byte where possible. + Tools assume this exactly; don't add helpful fields. +- Auth: `Authorization: Bearer pot___`. Keys are + SHA-256 hashed at rest; plaintext shown once at creation. Validation is + checksum-first (no DB), then DB lookup for existence + revocation. Both + steps use constant-time comparison. +- Errors use OpenAI's envelope: + `{ "error": { "message", "type", "code", "param" } }`. Mapping from internal + codes lives in `internal/api/v1/errors.go`. +- Idempotency: honor `Idempotency-Key` header. Same key within the dedup + window returns the cached response, doesn't re-call pioneer. +- Response headers always include `x-ratelimit-*` (matching OpenAI's names) + and `x-potluck-balance-cents` (ours, non-standard). +- `last_used_at` updates on `api_keys` are debounced — at most one write per + key per minute. Don't write on every request; you'll thrash SQLite's WAL. + +### API key format +Keys follow the format `pot__<18-char-base62>_<5-char-checksum>`: + +``` +pot_cedar_KJ3mN8pQwR5vX2yZ4b_9xK2m +pot_thyme_aB7cD2eF5gH1iJ4kL6_mN3pQ +``` + +- `pot_` — project prefix; grep-able for secret scanning +- `` — one of ~256 curated single/double-syllable words (herbs, + materials, nature). Makes keys human-referenceable ("my cedar key") and + visually distinct. Not a security input. +- `<18-char-base62>` — 107 bits of entropy from `crypto/rand` +- `<5-char-checksum>` — `base62(SHA256(payload)[0:3])`, where `payload` is + everything before the last `_`. Lets middleware fast-fail typos without a + DB hit. Not a security gate — the DB lookup is. + +**Validation** is two-step: checksum first (no DB), then DB lookup for +existence + revocation. Reject in `constant time` on every failure path. + +**Dummy/test key** for docs, tests, and fixtures: +``` +pot_test_000000000000000000_ +``` +Pre-compute the checksum once and commit it as a constant in +`internal/auth/apikeys.go`. Stable across runs. + +**Masked display** in the UI — show word and checksum, mask entropy: +``` +pot_cedar_••••••••••••••••••_9xK2m +``` + +### API key lifecycle +- Keys are minted from the web UI (`/settings/keys`), shown plaintext in a + modal exactly once, never retrievable again. +- Users name keys on creation. Rotation = create new, switch app, revoke old. + No "primary key" concept. +- Per-key `max_budget` is optional and bounded by the owner's available + balance — a key budget never lets the user exceed their pool share. +- Revocation is a soft delete (`revoked_at`). Keep the row for audit; the + DB lookup just excludes revoked keys. + +### Accounting +- Pre-flight check: a user cannot **start** a new stream if balance is below + `MinBalanceToStart` (`$0.25` default) or if they have `MaxConcurrentStreams` + already in flight (`3` default). +- In-flight streams always complete and settle, even if they push balance + negative. The hard floor + concurrency cap bounds the exposure. +- Settlement uses the provider's `usage` chunk when present; falls back to + local tokenization and marks the spend row `is_estimated = true` for the + nightly reconciliation job to fix. + +### Errors +- `errors.New` / `fmt.Errorf("...: %w", err)`. No third-party error libs. +- User-facing errors include a stable `code` string (e.g. + `"insufficient_funds"`, `"rate_limited"`, `"provider_down"`) that the + frontend matches on. Never match on message text. + +### Logging +- `slog` with a request-scoped logger carrying `request_id`, `user_id`, + `stream_id` where relevant. Propagate via `context.Context`. +- No PII in logs beyond user IDs. **Never log message contents.** + +### Testing +- A fake pioneer at `internal/fakeprovider` replays canned SSE fixtures. + Use it for all stream tests; never hit real pioneer in tests. +- Required coverage on any change to `internal/stream`: + 1. Resume after mid-stream disconnect + 2. Idempotent stream creation (same key → same stream) + 3. Budget exhaustion mid-stream (in-flight completes, new requests rejected) + 4. Slow subscriber doesn't stall producer + +## Things NOT to do + +These are decisions already made. If you think one is wrong, open a `design/` +doc proposing the change before writing code. + +- **Don't introduce LiteLLM** or any other multi-provider gateway. One + provider; the unification layer is overhead, not value. +- **Don't add WebSockets.** SSE handles everything we need. WebSockets only + enter scope if we add voice or multi-user shared canvases. +- **Don't use `@ai-sdk/svelte` `Chat`.** It owns its own state machine and + fights with our Dexie-first model. The thin transport in + `web/src/lib/stream.ts` is the supported path. +- **Don't cancel the upstream when the first client disconnects.** Throws + away expensive generations and breaks cross-device resume. +- **Don't use floats for money.** Anywhere. +- **Don't put auth or business logic in the Cloudflare Worker.** SvelteKit's + server runtime IS the Worker; treat its server-side surface as a CDN plus + dumb `/api` proxy. All real work is in the Go backend. +- **Don't use `+page.server.ts` or `+layout.server.ts` for data loading.** + Data flow is client-first via Dexie + the `/api` client. Server load + functions defeat the local-first model and force a round trip on every + navigation. +- **Don't use Svelte 4 legacy reactivity** (`$:`, store auto-subscriptions + outside of templates, reassignment-triggers-update). Runes only: + `$state`, `$derived`, `$effect`, `$props`. +- **Don't reference raw palette colors in components.** Use semantic tokens + (`var(--bg-page)`, `var(--text)`, `var(--accent)`, etc.). The raw palette + exists only in `tokens.css`. +- **Don't add a `data-theme` attribute or `prefers-color-scheme` media + queries.** Theming is driven entirely by `` and + the CSS `light-dark()` function. See `theme.ts`. +- **Don't use IBM Plex Mono Variable.** Static 400/500 only; the variable + build renders tabular figures poorly at small UI sizes. +- **Don't change the theme cycle order.** It's `auto → dark → light → auto` + by design — first tap commits to a dark preference (most users want this); + third tap returns control to the OS. +- **Don't load fonts from Google Fonts or other CDNs.** Self-hosted only, + via `@fontsource[-variable]/*`. Latin subset only. +- **Don't `import` Fraunces or Inter from a CSS file in a component.** + Fonts are imported once, in `+layout.svelte`. Per-component imports + duplicate the woff2s in the build. +- **Don't add a second LLM provider** without also updating: `model_prices`, + the `provider/` package, the compatibility-check probe, and this file. +- **Don't edit applied migrations.** Always write a new one. +- **Don't store provider API keys in the DB.** Env vars only, loaded once at + startup. +- **Don't add an ORM.** `sqlc` is the boundary. +- **Don't mix `/api/*` and `/v1/*` route trees or middleware.** They share + the auth/balance/rate-limit pipeline by composition, not by sharing + handlers. Different surfaces, different error shapes, different streaming + semantics. +- **Don't try to make `/v1/*` resumable.** OpenAI's API isn't, no client + expects it, and adding the tee machinery there means burning pioneer + budget on tokens nobody will read. Client disconnect on `/v1/*` cancels + the upstream call. Period. +- **Don't return potluck-internal error shapes from `/v1/*`.** Always use + the OpenAI envelope. Map via `internal/api/v1/errors.go`. +- **Don't store API keys in plaintext.** SHA-256 hash at write. The `key_hash` + column is `UNIQUE`; collisions (vanishingly unlikely) fail loud. Store + `key_word` and `key_last4` separately for display. +- **Don't skip the checksum step.** `ValidKey()` runs before the DB lookup + on every request — if the checksum fails, return 401 without touching + SQLite. The checksum is not a security gate but it is a cheap first filter. +- **Don't write `api_keys.last_used_at` on every request.** Debounce to at + most once per minute per key. +- **Don't let `/v1/*` see web-UI conversations or vice versa.** They share + spend tracking and rate limits, nothing else. An API key cannot list a + user's chat history; a chat session cannot list a user's API keys for + another user. + +## Critical files (read before editing) + +- `server/internal/stream/tee.go` — SSE fan-out for the internal `/api/*` + surface. Subtle correctness properties; see comments and + `design/streaming.md`. +- `server/internal/api/v1/proxy.go` — public `/v1/*` pass-through. Much + simpler than the tee but its own correctness gotchas around cancellation + and error mapping. +- `server/internal/api/v1/errors.go` — internal-code → OpenAI-shape mapping. + Every new error code added anywhere must be mapped here, or `/v1/*` clients + see naked 500s. +- `server/internal/api/middleware/` — auth, balance, rate limit, spend + recording. Shared between both surfaces; changes affect everything. +- `server/internal/auth/apikeys.go` — key generation, hashing, lookup, + revocation. +- `server/internal/ledger/balance.go` — contribution + spend math. Touched + by every request. +- `server/internal/provider/pioneer.go` — the only place that talks to + pioneer.ai. Quirks documented here as discovered. +- `server/db/migrations/` — append-only history of the schema. +- `web/src/lib/stream.ts` — client-side SSE consumer with resume. +- `web/src/lib/db.ts` — Dexie schema; conversations and messages. +- `web/src/lib/styles/tokens.css` — palette, semantic tokens, type defaults. + Touch this and you touch every component. +- `web/src/lib/theme.ts` — theme switcher API (`currentTheme`, `setTheme`, + `cycleTheme`). +- `web/src/app.html` — meta tag + inline boot script. The boot script must + stay synchronous and before stylesheets; do not move it to a module. + +## Provider notes (pioneer.ai) + +OpenAI-compatible chat completions API. Specifics to verify and pin in +`provider/pioneer.go`: + +- [ ] Does `stream_options.include_usage` emit usage in the final chunk? + If not, fall back to local tokenization for every request. +- [ ] Tool-call delta format (full args per chunk vs accumulated string) +- [ ] Reasoning content field name, if any (`reasoning_content`, inline, + none?) +- [ ] Available models and per-token pricing → seed `model_prices` +- [ ] Error format mid-stream (event named `error`, chunk with `error` + field, or silent EOS?) +- [ ] Rate-limit headers and their meaning +- [ ] Whether the tokenizer matches OpenAI's `cl100k_base`/`o200k_base` or + something custom + +Update this file (and add a fixture in `fakeprovider`) every time a new +quirk is discovered. + +## External services + +| Service | Purpose | Env var | +|---|---|---| +| pioneer.ai | LLM provider | `PIONEER_API_KEY`, `PIONEER_BASE_URL` | +| Backblaze B2 | Litestream backup target | `LITESTREAM_B2_*` | +| ntfy.sh | Alerting | `NTFY_TOPIC`, `NTFY_TOKEN` | +| Cloudflare | Worker hosting | (configured in `wrangler.toml`) | + +## See also + +- `design/streaming.md` — sequence diagrams for tee, resume, settlement +- `design/accounting.md` — reservation model, reconciliation cron +- `design/security.md` — auth flow, session handling, key isolation +- `design/deployment.md` — NixOS module, GitHub Actions, rollback +- `design/design-system.md` — palette rationale, type scale, component patterns +- `design/public-api.md` — supported endpoints, error mapping, idempotency, + rate-limit headers, what differs from OpenAI \ No newline at end of file diff --git a/CONFIG.md b/CONFIG.md new file mode 100644 index 0000000..5afb625 --- /dev/null +++ b/CONFIG.md @@ -0,0 +1,31 @@ +# Environment Variables + +## Config + +Config wraps every server-level setting with sensible defaults. + + - `POTLUCK_HTTP_LISTEN_ADDR` (default: `:8080`) - HTTP listen address. + - `POTLUCK_DB` (default: `data/potluck.db`) - SQLite database path. Litestream replicates this file. + - `POTLUCK_ENVIRONMENT` (default: `local`) - Environment the app is running in. Options: local or production. + - `POTLUCK_BASE_URL` (default: `http://localhost:8080`) - Public-facing base URL of the application. Used for OAuth redirects +and email links once those land. + - `POTLUCK_SESSION_SECRET` (default: `dev-only-not-secret`) - Session secret signing the cookie store. Generate with: + openssl rand -base64 32 +Required in production; dev has no default to nudge you to set one. + - `POTLUCK_SESSION_TTL` (default: `7776000`) - Session cookie TTL. Idle sessions die after this many seconds of +inactivity (the last_used_at column is bumped on every request). + - Pioneer.ai inference credentials. + - `PIONEER_API_KEY` - API key. Required; the server refuses to start without it in production. + - `PIONEER_BASE_URL` (default: `https://api.pioneer.ai`) - Base URL — override for testing against the fake provider. + - Spend policy. + - `POTLUCK_SPEND_MIN_BALANCE_MICROS` (default: `250000`) - Minimum balance (USD micros) below which new streams are rejected. +Default = $0.25 in micros. + - `POTLUCK_SPEND_MAX_CONCURRENT_STREAMS` (default: `3`) - Maximum streams a single user can have in flight at once. + - Litestream replication. + - `LITESTREAM_B2_BUCKET` - B2 bucket name. + - `LITESTREAM_B2_KEY_ID` - B2 application key id. + - `LITESTREAM_B2_APPLICATION_KEY` - B2 application key. + - Notifications via ntfy.sh. + - `NTFY_TOPIC` - Topic name to publish to. + - `NTFY_TOKEN` - Bearer token for authenticated topics. + diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..0297ba7 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,169 @@ +# https://taskfile.dev +# +# potluck task runner. +# - colon-namespaced tasks (watch:server, watch:web, npm:install) +# - watch:all aliased to `watch` runs all watchers in parallel via deps +# - default alias points at `server` so bare `task` boots the backend +# - sources/generates make codegen tasks idempotent +# - status: skips work that's already done + +version: "3" + +tasks: + server: + desc: Run server (auto-migrate) + aliases: [default] + dir: server + cmds: + - go run ./cmd/server --auto-migrate {{.CLI_ARGS}} + + test: + desc: Run all tests + deps: [test:server, test:web] + + test:server: + desc: Go tests + dir: server + cmds: + - go test ./... {{.CLI_ARGS}} + + test:web: + desc: Web unit tests + dir: web + cmds: + - bun run test {{.CLI_ARGS}} + + fmt: + desc: Format Go and web sources + cmds: + - cd server && gofmt -w . + - cd web && bun run fmt + + lint: + desc: Lint + cmds: + - cd server && go vet ./... + - cd web && bun run lint + + # ---- dev: parallel watchers --------------------------------------------- + + watch:all: + desc: Watch backend and web in parallel + aliases: [watch, dev] + deps: [watch:server, watch:web] + + watch:server: + desc: Watch the Go backend with air + dir: server + cmds: + - air -c ../.air.toml + + watch:web: + desc: Watch the SvelteKit web app + deps: [bun:install] + dir: web + cmds: + - bun run dev + + # ---- build -------------------------------------------------------------- + + build: + desc: Build server binary and web bundle + deps: [build:server, build:web] + + build:server: + desc: Static Go binary at server/dist/server + dir: server + env: + CGO_ENABLED: "0" + cmds: + - go build -trimpath -ldflags='-s -w' -o dist/server ./cmd/server + + build:web: + desc: SvelteKit production bundle + deps: [bun:install] + dir: web + cmds: + - bun run build + + # ---- dependencies ------------------------------------------------------- + + bun:install: + desc: Install web dependencies + dir: web + sources: + - package.json + - bun.lock + - bun.lockb + cmds: + - bun install + + # ---- codegen ------------------------------------------------------------ + + sqlc: + desc: Regenerate sqlc query code + dir: server + sources: + - sqlc.yaml + - db/queries/*.sql + - db/migrations/*.sql + generates: + - internal/store/*.sql.go + - internal/store/models.go + - internal/store/db.go + - internal/store/querier.go + cmds: + - sqlc generate + + generate: + desc: Run all `go generate` directives (regenerates CONFIG.md) + aliases: [gen] + dir: server + sources: + - internal/config/*.go + generates: + - ../CONFIG.md + cmds: + - go generate ./... + + # ---- migrations --------------------------------------------------------- + + migrate:new: + desc: "Create a new migration: task migrate:new -- add_widgets" + dir: server + cmds: + - goose -dir db/migrations create {{.CLI_ARGS}} sql + # keep the embed copy in sync + - cp db/migrations/*.sql internal/migrations/files/ + + migrate:up: + desc: Apply pending migrations + dir: server + cmds: + - goose -dir db/migrations sqlite3 ../data/potluck.db up + + migrate:down: + desc: Roll back the last migration + dir: server + cmds: + - goose -dir db/migrations sqlite3 ../data/potluck.db down + + # ---- deploy ------------------------------------------------------------- + + deploy:server: + desc: Deploy backend to terebithia (TODO) + cmds: + - 'echo "TODO scp dist/server to terebithia and systemctl restart"' + + deploy:web: + desc: Deploy SPA to Cloudflare + dir: web + cmds: + - bunx wrangler deploy + + # ---- housekeeping ------------------------------------------------------- + + clean: + desc: Remove build artefacts + cmds: + - rm -rf server/dist server/tmp web/.svelte-kit web/build web/dist web/.wrangler diff --git a/design/accounting.md b/design/accounting.md new file mode 100644 index 0000000..7eb438e --- /dev/null +++ b/design/accounting.md @@ -0,0 +1,51 @@ +# accounting + +Money lives in two tables: `contributions` (positive deposits) and `spends` +(positive charges, one per stream). Balance is `sum(contributions) - +sum(spends)`. We don't keep a denormalised running total — at ~10 users this +is fine. + +All values are int64 micros (1 USD = 1,000,000). See `internal/money`. + +## Pre-flight check + +Before opening a stream the server calls `ledger.CanStart`: + +1. Load balance. +2. Reject if balance < `MinBalanceToStart` (default $0.25). +3. Count active streams for the user. +4. Reject if the count >= `MaxConcurrentStreams` (default 3). + +These bounds cap the worst-case overrun if a user runs every concurrent +stream into a long generation right at the floor. + +## Settlement + +Streams settle once, at end of stream: + +- If pioneer emits a `usage` chunk (with `stream_options.include_usage` + on), use those token counts. +- Otherwise tokenize the prompt + accumulated assistant text locally and + mark the spend `is_estimated = true` for the nightly reconciliation + job to revisit. + +Settlement is idempotent on `streams.id`; `spends` has `UNIQUE(stream_id)`. + +## Reconciliation + +A nightly job (cron, not yet built) reads `spends WHERE is_estimated = 1`, +re-tokenises against the canonical tokenizer, and corrects `amount_micros`. +Differences between estimate and truth show up as positive or negative +adjustments to balance. + +## Why no reservations + +A reservation system (set aside the worst-case spend at start, refund the +unused part at end) sounds tidier but creates two failure modes: + +1. Crash between reservation and refund leaks money until reconciliation. +2. The user's effective balance is lower than what they see in the UI. + +With the floor + cap policy, the worst-case overrun is bounded by +`MaxConcurrentStreams × max_completion_cost`, which at $0.05/long-generation +is a couple of dollars across the entire pool. Acceptable. diff --git a/design/deployment.md b/design/deployment.md new file mode 100644 index 0000000..e95111b --- /dev/null +++ b/design/deployment.md @@ -0,0 +1,70 @@ +# deployment + +## Targets + +- Backend (Go binary) → terebithia (aarch64 NixOS). +- Web (SvelteKit Cloudflare Worker) → `potluck.dunkirk.sh` via Wrangler. + +## Backend on terebithia + +Pulled in as a flake via `nix/flake.nix`. The host's NixOS config consumes +`nixosModules.default` and enables the service: + +```nix +{ + imports = [ inputs.potluck.nixosModules.default ]; + + services.potluck = { + enable = true; + port = 8080; + environmentFile = config.age.secrets.potluck.path; + }; + + # Reverse proxy via Caddy or nginx — terminate TLS, forward /api to :8080. +} +``` + +The agenix secret holds: + +``` +PIONEER_API_KEY=... +POTLUCK_SESSION_SECRET=... +LITESTREAM_B2_BUCKET=... +LITESTREAM_B2_KEY_ID=... +LITESTREAM_B2_APPLICATION_KEY=... +NTFY_TOPIC=... +NTFY_TOKEN=... +``` + +State lives at `/var/lib/potluck/potluck.db`. Litestream replicates it to +B2 continuously; restore is `litestream restore` followed by a service +restart. + +## Web + +``` +task build-web +task deploy-web # bunx wrangler deploy +``` + +`web/wrangler.toml` is checked in. The `BACKEND_URL` var points at the +public hostname of the backend; the Worker proxies `/api/*` straight +through. + +## CI + +GitHub Actions (TODO) should run on push to main: + +1. `task test` +2. `task build` +3. SSH to terebithia, copy binary, `systemctl restart potluck`. +4. `task deploy-web`. + +Roll back by re-running the previous workflow run. + +## Health + +- `GET /healthz` returns 200 "ok". +- systemd's Restart=on-failure handles crash loops. +- ntfy.sh receives alerts on health-check failures or Litestream errors + (not yet wired). diff --git a/design/design-system.md b/design/design-system.md new file mode 100644 index 0000000..e5b57b0 --- /dev/null +++ b/design/design-system.md @@ -0,0 +1,76 @@ +# design system + +The full token table lives in `web/src/lib/styles/tokens.css`. This doc +covers rationale. + +## Palette + +Six brand colors. Two cream-leaning warm tones, two pinks (one for light, +one for dark), and two near-blacks. The palette is small on purpose — every +component renders in terms of semantic tokens, never raw colors, so +adjusting the palette swings the whole app at once. + +| Token | Hex | Job | +|---|---|---| +| `--soft-blush` | `#ffd9da` | warm tint, text on dark surfaces | +| `--blush-rose` | `#ea638c` | accent on dark mode | +| `--dark-raspberry` | `#89023e` | accent on light mode | +| `--jet-black` | `#30343f` | text on light, surface on dark | +| `--carbon-black` | `#1b2021` | page bg on dark | +| `--paper` | `#fffaf9` | page bg on light | + +### Accent role swap + +`--dark-raspberry` is dark enough to read as a link on the cream `--paper` +background. On `--carbon-black` the same color disappears at small sizes, +so the dark theme promotes `--blush-rose` to accent duty. The +`--accent` semantic token bakes this in: + +```css +--accent: light-dark(var(--dark-raspberry), var(--blush-rose)); +``` + +Components reference `var(--accent)`. Components never reference the raw +palette names. + +## Theming + +Driven entirely by `` and the CSS `light-dark()` +function — no `data-theme`, no `[prefers-color-scheme]` media queries. +`web/src/lib/theme.ts` mutates the meta tag's `content`; the cascade does +the rest. + +The boot script in `app.html` runs synchronously before any stylesheet, +restoring the persisted preference. That kills the flash of wrong theme. + +Cycle order: `auto → dark → light → auto`. First tap commits to dark +(most users want this); the third tap returns control to the OS. Don't +change the order — it's load-bearing for muscle memory. + +## Type + +- **Fraunces Variable** — display headlines. Use + `font-variation-settings: var(--fraunces-display)` (`opsz 144, SOFT 50`) + at ~20px and up; `var(--fraunces-text)` (`opsz 9, SOFT 0`) below. +- **Inter Variable** — body and UI. +- **IBM Plex Mono** — code and all numeric stats. Static 400/500 only. + The variable build renders tabular figures poorly at small UI sizes. + +`tnum` is on by default at the root so numbers in tables don't wobble. +Override with `font-feature-settings: "tnum" 0` for prose where you want +proportional digits. + +Critical woff2s are ``-ed from `+layout.svelte` to +avoid FOIT. + +## Component patterns + +- Sidebar + main grid, mirrors Hack Club Auth's docs layout. +- Cards: 1px `--border`, 12px radius, `--bg-surface`, breathing 1.25rem + padding. +- Buttons: filled with `--accent`, text on accent uses `--text-on-accent`. +- All numeric stats use `.num` (mono, tabular). + +Don't add a component library yet. The taxonomy is small enough that bare +CSS scales for now; if a third surface needs the same card pattern, then +extract. diff --git a/design/security.md b/design/security.md new file mode 100644 index 0000000..e31fb24 --- /dev/null +++ b/design/security.md @@ -0,0 +1,48 @@ +# security + +Single-deployment hobby project for ~10 trusted friends. Threat model is +"don't leak the pooled API key, don't let a stranger spend our money," +not "withstand state-level attacker." + +## Sessions + +- Cookie-based opaque tokens (32 random bytes hex-encoded). +- Server stores only the SHA-256 hash; the plaintext lives on the client. +- TTL is 90 days, refreshed on use (last_used_at). +- HttpOnly, SameSite=Lax. Secure in production; the dev login does not + set Secure to keep localhost workable. + +## Auth provider (TODO) + +The dev login (`POST /api/dev/login?email=...`) is a stand-in. The intended +real flow is OAuth/OIDC against Hack Club Auth. When that lands, replace +`/api/dev/login` with a real `/api/auth/{login,callback}` pair. + +## Provider key isolation + +`PIONEER_API_KEY` is loaded once in `config.Load` and held in process +memory. It never enters the database, never leaves the backend, and is +never logged. A leak of the SQLite file does not leak the key. + +## Worker boundary + +The Cloudflare Worker forwards `/api/*` to the Go backend with no logic of +its own. All auth happens on the backend. Don't put session checks in the +Worker — there's nothing to enforce there and you'd just be moving the +trust boundary into a place that can't talk to the DB. + +## Logging + +`slog` propagates a request-scoped logger that carries `request_id`, +`user_id`, and (where relevant) `stream_id`. We never log message +contents, prompts, or completions. PII budget is "user IDs and emails for +audit"; nothing else. + +## Key rotation + +Rotating `PIONEER_API_KEY` is a config redeploy. Old sessions remain valid +because they don't reference the provider key. + +Rotating `POTLUCK_SESSION_SECRET` invalidates all sessions (currently +unused — sessions are opaque DB rows, not signed cookies — but the env +var exists for future signed-cookie flows). diff --git a/design/streaming.md b/design/streaming.md new file mode 100644 index 0000000..45fd19f --- /dev/null +++ b/design/streaming.md @@ -0,0 +1,68 @@ +# streaming + +How the SSE tee works end to end. Source of truth for the `internal/stream` +package; the package doc points here. + +## Goals + +- A single upstream call to pioneer.ai per generation, regardless of how + many clients attach. +- Client disconnect must NOT cancel the upstream call. +- A late or reconnecting client can resume from any point without missing + chunks or seeing duplicates. +- A slow subscriber must not stall the producer. + +## Components + +- `provider.Client` — owns the HTTPS connection to pioneer. +- `stream.Producer` — one goroutine per stream, drains the provider's chunk + channel, persists each event, then publishes to the `Bus`. +- `stream.Bus` — in-memory fan-out; one per stream id; subscribers are + buffered channels. +- `stream.Hub` — `map[stream_id]*Bus`, keeps the buses alive while at least + one subscriber or producer references them. +- `stream_chunks` table — durable replay log; PK `(stream_id, seq)`. + +## Sequence — happy path + +``` +client worker backend pioneer + │ │ POST /api/chat │ │ + │ ───────────────────►│ ───────────────────► │ │ + │ │ │ POST /v1/chat/... │ + │ │ │ ──────────────────► │ + │ │ 200 stream_id │ │ + │ ◄───────────────────│ ◄─────────────────── │ │ + │ GET .../events │ │ │ + │ ───────────────────►│ ───────────────────► │ subscribe(bus) │ + │ │ │ persist chunk N │ + │ │ │ publish chunk N │ + │ ◄───────────────────│ ◄─────────────────── │ │ + │ ... │ │ ... │ + │ │ │ data: [DONE] │ + │ │ │ ◄────────────────── │ + │ done │ │ │ +``` + +## Sequence — resume after disconnect + +The client tracks `lastSeq`. On reconnect it sends +`GET .../events?after_seq=lastSeq`. The server replays +`stream_chunks WHERE seq > lastSeq` from DB, then attaches the client to +the live bus. Because the producer persists before publishing, every +seq the client saw is also durable in `stream_chunks`. + +## Producer context isolation + +Producers run on `context.Background()`-derived contexts. The HTTP request +that started the stream returns immediately after enqueuing the producer. +A client closing its tab cannot cancel the upstream call. This burns a +few cents on abandoned generations — acceptable cost for cross-device +resume and not throwing away expensive reasoning chains. + +## Slow subscriber policy + +Each subscriber gets a buffered channel. If a publish would block, the +bus drops the subscriber (closes the channel). The client's own consume +loop notices the EOF and reconnects with `after_seq`, replays the missed +chunks from DB, and resumes live. The producer never blocks. diff --git a/nix/flake.nix b/nix/flake.nix new file mode 100644 index 0000000..03320ac --- /dev/null +++ b/nix/flake.nix @@ -0,0 +1,47 @@ +{ + description = "potluck — pooled chat frontend over pioneer.ai"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + let + systems = [ "aarch64-linux" "x86_64-linux" "aarch64-darwin" "x86_64-darwin" ]; + in + flake-utils.lib.eachSystem systems (system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + packages.default = pkgs.buildGoModule { + pname = "potluck"; + version = "0.0.1"; + src = ./server; + # Update on dependency change: nix build will print expected hash. + vendorHash = null; + subPackages = [ "cmd/server" ]; + ldflags = [ "-s" "-w" ]; + # CGO-free: modernc.org/sqlite is pure Go. + CGO_ENABLED = "0"; + }; + packages.server = self.packages.${system}.default; + + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + go + go-task + sqlc + goose + air + bun + nodejs + ]; + }; + }) + // { + nixosModules.default = import ./module.nix self; + nixosModules.potluck = self.nixosModules.default; + }; +} diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 0000000..43af6cb --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,95 @@ +# NixOS module for potluck. +# +# Wraps the server binary as a systemd unit. Intended to be consumed via +# the flake's `nixosModules.default`. Pair with an agenix secret that +# defines the EnvironmentFile. +self: { config, lib, pkgs, ... }: +let + cfg = config.services.potluck; +in +{ + options.services.potluck = { + enable = lib.mkEnableOption "potluck — pooled pioneer.ai chat frontend"; + + package = lib.mkOption { + type = lib.types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.default; + description = "potluck server package."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8080; + description = "TCP port the server listens on."; + }; + + dataDir = lib.mkOption { + type = lib.types.path; + default = "/var/lib/potluck"; + description = "Directory holding the SQLite database."; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "potluck"; + description = "System user the service runs as."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "potluck"; + description = "System group the service runs as."; + }; + + environmentFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + Path to an EnvironmentFile defining secrets like PIONEER_API_KEY, + POTLUCK_SESSION_SECRET, and the LITESTREAM_B2_* trio. Best supplied + via agenix. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.dataDir; + createHome = true; + }; + users.groups.${cfg.group} = { }; + + systemd.services.potluck = { + description = "potluck — pooled pioneer.ai chat frontend"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + environment = { + POTLUCK_ADDR = ":${toString cfg.port}"; + POTLUCK_DB = "${cfg.dataDir}/potluck.db"; + }; + + serviceConfig = { + ExecStart = "${cfg.package}/bin/server"; + WorkingDirectory = cfg.dataDir; + User = cfg.user; + Group = cfg.group; + Restart = "on-failure"; + RestartSec = 2; + StateDirectory = "potluck"; + + # hardening + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ReadWritePaths = [ cfg.dataDir ]; + } // lib.optionalAttrs (cfg.environmentFile != null) { + EnvironmentFile = cfg.environmentFile; + }; + }; + }; +} diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go new file mode 100644 index 0000000..09c32fc --- /dev/null +++ b/server/cmd/server/main.go @@ -0,0 +1,181 @@ +// Command server is the potluck backend. +// +// It owns auth, conversations, messages, the ledger, and the SSE stream +// tee. The Cloudflare Worker in /web is a dumb static-asset server plus +// /api/* proxy that forwards to here. +package main + +import ( + "context" + "database/sql" + "errors" + "flag" + "log/slog" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/google/uuid" + "golang.org/x/time/rate" + + "github.com/taciturnaxolotl/potluck/internal/api" + "github.com/taciturnaxolotl/potluck/internal/auth" + "github.com/taciturnaxolotl/potluck/internal/config" + "github.com/taciturnaxolotl/potluck/internal/ledger" + "github.com/taciturnaxolotl/potluck/internal/migrations" + "github.com/taciturnaxolotl/potluck/internal/money" + "github.com/taciturnaxolotl/potluck/internal/store" + "github.com/taciturnaxolotl/potluck/internal/stream" + + _ "modernc.org/sqlite" +) + +func main() { + autoMigrate := flag.Bool("auto-migrate", false, "run pending migrations on boot") + flag.Parse() + + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + slog.SetDefault(logger) + + cfg := config.MustGet() + + if dir := filepath.Dir(cfg.DatabaseURL); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + logger.Error("mkdir db dir", "err", err) + os.Exit(2) + } + } + + db, err := sql.Open("sqlite", cfg.DatabaseURL+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)") + if err != nil { + logger.Error("open db", "err", err) + os.Exit(2) + } + defer db.Close() + + if *autoMigrate { + if err := migrations.Run(db); err != nil { + logger.Error("migrate", "err", err) + os.Exit(2) + } + } + + q := store.New(db) + authSvc := auth.New(q, time.Duration(cfg.SessionTTL)*time.Second) + ledg := ledger.New(q, + money.Micros(cfg.Spend.MinBalanceToStartMicros), + cfg.Spend.MaxConcurrentStreams, + ) + hub := stream.NewHub(q) + + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(slogRequest(logger)) + r.Use(middleware.Recoverer) + r.Use(authSvc.Middleware) + + r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + + // Local-only login: trade an email for a session cookie. Real auth + // lives behind a real provider — see design/security.md. + if cfg.IsLocal() { + r.Post("/api/dev/login", func(w http.ResponseWriter, r *http.Request) { + email := r.URL.Query().Get("email") + if email == "" { + http.Error(w, "missing email", 400) + return + } + u, err := q.GetUserByEmail(r.Context(), email) + if errors.Is(err, sql.ErrNoRows) { + u, err = q.CreateUser(r.Context(), store.CreateUserParams{ + ID: uuid.NewString(), + Email: email, + DisplayName: email, + CreatedAt: time.Now().Unix(), + }) + } + if err != nil { + http.Error(w, err.Error(), 500) + return + } + tok, err := authSvc.IssueSession(r.Context(), u.ID) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + http.SetCookie(w, &http.Cookie{ + Name: auth.CookieName, + Value: tok, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(time.Duration(cfg.SessionTTL) * time.Second), + }) + _, _ = w.Write([]byte("ok")) + }) + } + + apiSrv := &api.Server{ + Q: q, + Auth: authSvc, + Ledger: ledg, + Hub: hub, + Limiter: rate.NewLimiter(rate.Limit(50), 100), + } + + r.Route("/api", func(r chi.Router) { + r.Group(func(r chi.Router) { + r.Use(auth.Require) + apiSrv.Mount(r) + }) + }) + + srv := &http.Server{ + Addr: cfg.HTTPListen, + Handler: r, + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + logger.Info("listening", "addr", cfg.HTTPListen, "env", cfg.Environment) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("server", "err", err) + os.Exit(1) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + <-stop + logger.Info("shutting down") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = srv.Shutdown(ctx) +} + +// slogRequest logs each request with method, path, status, and duration. +func slogRequest(l *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + next.ServeHTTP(ww, r) + l.Info("http", + "method", r.Method, + "path", r.URL.Path, + "status", ww.Status(), + "bytes", ww.BytesWritten(), + "dur_ms", time.Since(start).Milliseconds(), + "req_id", middleware.GetReqID(r.Context()), + ) + }) + } +} diff --git a/server/db/migrations/00001_init.sql b/server/db/migrations/00001_init.sql new file mode 100644 index 0000000..6c3e1d4 --- /dev/null +++ b/server/db/migrations/00001_init.sql @@ -0,0 +1,119 @@ +-- +goose Up +-- +goose StatementBegin + +-- All money columns are int64 micros (1 USD = 1_000_000). Never floats. + +CREATE TABLE users ( + id TEXT PRIMARY KEY, -- uuid + email TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, -- unix seconds + last_seen_at INTEGER +) STRICT; + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, -- opaque token (hashed) + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + last_used_at INTEGER NOT NULL +) STRICT; +CREATE INDEX sessions_by_user ON sessions(user_id); + +-- Conversations and messages. +CREATE TABLE conversations ( + id TEXT PRIMARY KEY, -- uuid + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + archived_at INTEGER +) STRICT; +CREATE INDEX conversations_by_user ON conversations(user_id, updated_at DESC); + +CREATE TABLE messages ( + id TEXT PRIMARY KEY, -- server uuid + conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + client_id TEXT, -- idempotency key from client (uuidv7) + role TEXT NOT NULL, -- 'user' | 'assistant' | 'system' | 'tool' + content TEXT NOT NULL DEFAULT '', + model TEXT, -- provider model id (assistant only) + created_at INTEGER NOT NULL, + UNIQUE(conversation_id, client_id) +) STRICT; +CREATE INDEX messages_by_conversation ON messages(conversation_id, created_at); + +-- Streams: one per assistant generation. Chunks fan out from a stream. +CREATE TABLE streams ( + id TEXT PRIMARY KEY, -- uuid + conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + assistant_message_id TEXT REFERENCES messages(id), + idempotency_key TEXT NOT NULL, -- client supplies on POST /api/chat + model TEXT NOT NULL, + status TEXT NOT NULL, -- 'pending' | 'running' | 'done' | 'error' | 'canceled' + error_code TEXT, + error_message TEXT, + started_at INTEGER NOT NULL, + finished_at INTEGER, + UNIQUE(user_id, idempotency_key) +) STRICT; +CREATE INDEX streams_by_user ON streams(user_id, started_at DESC); + +CREATE TABLE stream_chunks ( + stream_id TEXT NOT NULL REFERENCES streams(id) ON DELETE CASCADE, + seq INTEGER NOT NULL, -- 1-based monotonic + event TEXT NOT NULL, -- 'delta' | 'usage' | 'error' | 'done' + data TEXT NOT NULL, -- JSON payload + created_at INTEGER NOT NULL, + PRIMARY KEY (stream_id, seq) +) STRICT, WITHOUT ROWID; + +-- Ledger. +-- contributions: positive amounts. spends: positive amounts charged against a stream. +-- Balance = sum(contributions.amount) - sum(spends.amount). +CREATE TABLE contributions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + amount_micros INTEGER NOT NULL CHECK (amount_micros > 0), + note TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL +) STRICT; +CREATE INDEX contributions_by_user ON contributions(user_id, created_at DESC); + +CREATE TABLE spends ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + stream_id TEXT NOT NULL REFERENCES streams(id) ON DELETE CASCADE, + model TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + amount_micros INTEGER NOT NULL CHECK (amount_micros >= 0), + is_estimated INTEGER NOT NULL DEFAULT 0, -- 1 if usage chunk missing; reconcile job fixes + created_at INTEGER NOT NULL, + UNIQUE(stream_id) +) STRICT; +CREATE INDEX spends_by_user ON spends(user_id, created_at DESC); + +-- Provider model price book. amount_micros_per_1k_tokens, refreshed manually. +CREATE TABLE model_prices ( + model TEXT PRIMARY KEY, + input_micros_per_1k INTEGER NOT NULL, + output_micros_per_1k INTEGER NOT NULL, + updated_at INTEGER NOT NULL +) STRICT; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS model_prices; +DROP TABLE IF EXISTS spends; +DROP TABLE IF EXISTS contributions; +DROP TABLE IF EXISTS stream_chunks; +DROP TABLE IF EXISTS streams; +DROP TABLE IF EXISTS messages; +DROP TABLE IF EXISTS conversations; +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS users; +-- +goose StatementEnd diff --git a/server/db/queries/conversations.sql b/server/db/queries/conversations.sql new file mode 100644 index 0000000..29cbbcf --- /dev/null +++ b/server/db/queries/conversations.sql @@ -0,0 +1,23 @@ +-- name: CreateConversation :one +INSERT INTO conversations (id, user_id, title, created_at, updated_at) +VALUES (?, ?, ?, ?, ?) +RETURNING *; + +-- name: GetConversation :one +SELECT * FROM conversations WHERE id = ? AND user_id = ?; + +-- name: ListConversationsForUser :many +SELECT * FROM conversations +WHERE user_id = ? AND archived_at IS NULL +ORDER BY updated_at DESC +LIMIT ?; + +-- name: UpdateConversationTitle :exec +UPDATE conversations SET title = ?, updated_at = ? +WHERE id = ? AND user_id = ?; + +-- name: ArchiveConversation :exec +UPDATE conversations SET archived_at = ? WHERE id = ? AND user_id = ?; + +-- name: TouchConversation :exec +UPDATE conversations SET updated_at = ? WHERE id = ?; diff --git a/server/db/queries/ledger.sql b/server/db/queries/ledger.sql new file mode 100644 index 0000000..0052761 --- /dev/null +++ b/server/db/queries/ledger.sql @@ -0,0 +1,31 @@ +-- name: CreateContribution :one +INSERT INTO contributions (id, user_id, amount_micros, note, created_at) +VALUES (?, ?, ?, ?, ?) +RETURNING *; + +-- name: ListContributionsForUser :many +SELECT * FROM contributions +WHERE user_id = ? +ORDER BY created_at DESC +LIMIT ?; + +-- name: UpsertSpend :one +INSERT INTO spends ( + id, user_id, stream_id, model, input_tokens, output_tokens, + amount_micros, is_estimated, created_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(stream_id) DO UPDATE SET + input_tokens = excluded.input_tokens, + output_tokens = excluded.output_tokens, + amount_micros = excluded.amount_micros, + is_estimated = excluded.is_estimated +RETURNING *; + +-- name: SumContributions :one +SELECT COALESCE(SUM(amount_micros), 0) FROM contributions WHERE user_id = ?; + +-- name: SumSpends :one +SELECT COALESCE(SUM(amount_micros), 0) FROM spends WHERE user_id = ?; + +-- name: ListEstimatedSpends :many +SELECT * FROM spends WHERE is_estimated = 1 ORDER BY created_at ASC LIMIT ?; diff --git a/server/db/queries/messages.sql b/server/db/queries/messages.sql new file mode 100644 index 0000000..f13f4ff --- /dev/null +++ b/server/db/queries/messages.sql @@ -0,0 +1,17 @@ +-- name: UpsertMessage :one +INSERT INTO messages (id, conversation_id, client_id, role, content, model, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(conversation_id, client_id) DO UPDATE SET + content = excluded.content +RETURNING *; + +-- name: GetMessage :one +SELECT * FROM messages WHERE id = ?; + +-- name: ListMessagesForConversation :many +SELECT * FROM messages +WHERE conversation_id = ? +ORDER BY created_at ASC, id ASC; + +-- name: AppendAssistantContent :exec +UPDATE messages SET content = content || ? WHERE id = ?; diff --git a/server/db/queries/prices.sql b/server/db/queries/prices.sql new file mode 100644 index 0000000..91e9bb4 --- /dev/null +++ b/server/db/queries/prices.sql @@ -0,0 +1,13 @@ +-- name: UpsertModelPrice :exec +INSERT INTO model_prices (model, input_micros_per_1k, output_micros_per_1k, updated_at) +VALUES (?, ?, ?, ?) +ON CONFLICT(model) DO UPDATE SET + input_micros_per_1k = excluded.input_micros_per_1k, + output_micros_per_1k = excluded.output_micros_per_1k, + updated_at = excluded.updated_at; + +-- name: GetModelPrice :one +SELECT * FROM model_prices WHERE model = ?; + +-- name: ListModelPrices :many +SELECT * FROM model_prices ORDER BY model ASC; diff --git a/server/db/queries/sessions.sql b/server/db/queries/sessions.sql new file mode 100644 index 0000000..dc7d90b --- /dev/null +++ b/server/db/queries/sessions.sql @@ -0,0 +1,16 @@ +-- name: CreateSession :one +INSERT INTO sessions (id, user_id, created_at, expires_at, last_used_at) +VALUES (?, ?, ?, ?, ?) +RETURNING *; + +-- name: GetSession :one +SELECT * FROM sessions WHERE id = ? AND expires_at > ?; + +-- name: TouchSession :exec +UPDATE sessions SET last_used_at = ? WHERE id = ?; + +-- name: DeleteSession :exec +DELETE FROM sessions WHERE id = ?; + +-- name: DeleteExpiredSessions :exec +DELETE FROM sessions WHERE expires_at <= ?; diff --git a/server/db/queries/streams.sql b/server/db/queries/streams.sql new file mode 100644 index 0000000..452f0d6 --- /dev/null +++ b/server/db/queries/streams.sql @@ -0,0 +1,33 @@ +-- name: CreateStream :one +INSERT INTO streams ( + id, conversation_id, user_id, assistant_message_id, idempotency_key, + model, status, started_at +) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?) +RETURNING *; + +-- name: GetStreamByIdempotencyKey :one +SELECT * FROM streams WHERE user_id = ? AND idempotency_key = ?; + +-- name: GetStream :one +SELECT * FROM streams WHERE id = ?; + +-- name: SetStreamStatus :exec +UPDATE streams SET status = ?, finished_at = ?, error_code = ?, error_message = ? +WHERE id = ?; + +-- name: CountActiveStreamsForUser :one +SELECT COUNT(*) FROM streams +WHERE user_id = ? AND status IN ('pending', 'running'); + +-- name: AppendStreamChunk :exec +INSERT INTO stream_chunks (stream_id, seq, event, data, created_at) +VALUES (?, ?, ?, ?, ?); + +-- name: ListStreamChunksAfter :many +SELECT seq, event, data, created_at +FROM stream_chunks +WHERE stream_id = ? AND seq > ? +ORDER BY seq ASC; + +-- name: MaxStreamChunkSeq :one +SELECT COALESCE(MAX(seq), 0) FROM stream_chunks WHERE stream_id = ?; diff --git a/server/db/queries/users.sql b/server/db/queries/users.sql new file mode 100644 index 0000000..e86fdc7 --- /dev/null +++ b/server/db/queries/users.sql @@ -0,0 +1,13 @@ +-- name: CreateUser :one +INSERT INTO users (id, email, display_name, created_at) +VALUES (?, ?, ?, ?) +RETURNING *; + +-- name: GetUserByID :one +SELECT * FROM users WHERE id = ?; + +-- name: GetUserByEmail :one +SELECT * FROM users WHERE email = ?; + +-- name: TouchUser :exec +UPDATE users SET last_seen_at = ? WHERE id = ?; diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..3f007e4 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,28 @@ +module github.com/taciturnaxolotl/potluck + +go 1.26.3 + +require ( + github.com/caarlos0/env/v11 v11.4.1 + github.com/go-chi/chi/v5 v5.2.5 + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + github.com/pressly/goose/v3 v3.27.1 + golang.org/x/time v0.15.0 + modernc.org/sqlite v1.50.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..7908000 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,74 @@ +github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= +github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= +github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= +modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/server/internal/api/api.go b/server/internal/api/api.go new file mode 100644 index 0000000..3516684 --- /dev/null +++ b/server/internal/api/api.go @@ -0,0 +1,188 @@ +// Package api wires HTTP handlers onto a chi router. +// +// All handlers in this package treat user identity as a precondition (see +// auth.Require). Public endpoints (login, healthz) are mounted directly in +// main. +package api + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "golang.org/x/time/rate" + + "github.com/taciturnaxolotl/potluck/internal/auth" + "github.com/taciturnaxolotl/potluck/internal/ledger" + "github.com/taciturnaxolotl/potluck/internal/store" + "github.com/taciturnaxolotl/potluck/internal/stream" +) + +type Server struct { + Q *store.Queries + Auth *auth.Service + Ledger *ledger.Service + Hub *stream.Hub + Limiter *rate.Limiter // crude global limiter; per-user comes later +} + +// Mount registers routes on r. Caller is expected to wrap with auth middleware. +func (s *Server) Mount(r chi.Router) { + r.Get("/me", s.handleMe) + r.Get("/balance", s.handleBalance) + r.Post("/contributions", s.handleContribute) + + r.Get("/conversations", s.handleListConversations) + r.Post("/conversations", s.handleCreateConversation) + r.Get("/conversations/{id}", s.handleGetConversation) + r.Get("/conversations/{id}/messages", s.handleListMessages) + + r.Post("/chat", s.handleChat) + r.Get("/streams/{id}/events", s.handleStreamEvents) +} + +// ---- helpers ------------------------------------------------------------ + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func writeErr(w http.ResponseWriter, code int, errCode, msg string) { + writeJSON(w, code, map[string]any{"error": map[string]string{"code": errCode, "message": msg}}) +} + +// ---- handlers ---------------------------------------------------------- + +func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { + u, _ := auth.UserFromContext(r.Context()) + writeJSON(w, 200, u) +} + +func (s *Server) handleBalance(w http.ResponseWriter, r *http.Request) { + u, _ := auth.UserFromContext(r.Context()) + bal, err := s.Ledger.Balance(r.Context(), u.ID) + if err != nil { + writeErr(w, 500, "internal", err.Error()) + return + } + writeJSON(w, 200, map[string]any{ + "balance_micros": int64(bal), + "balance_usd": bal.USDString(), + }) +} + +type contributeReq struct { + AmountUSD string `json:"amount_usd"` + Note string `json:"note"` +} + +func (s *Server) handleContribute(w http.ResponseWriter, r *http.Request) { + u, _ := auth.UserFromContext(r.Context()) + var req contributeReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, 400, "bad_request", err.Error()) + return + } + // money.ParseUSD lives in the money pkg but we lean on it indirectly via ledger. + // Importing money here just for the parse is fine in a follow-up; for now + // require the client to send micros. + writeErr(w, 501, "not_implemented", "send amount_micros instead; client conversion pending") + _ = u +} + +func (s *Server) handleListConversations(w http.ResponseWriter, r *http.Request) { + u, _ := auth.UserFromContext(r.Context()) + convs, err := s.Q.ListConversationsForUser(r.Context(), store.ListConversationsForUserParams{UserID: u.ID, Limit: 100}) + if err != nil { + writeErr(w, 500, "internal", err.Error()) + return + } + writeJSON(w, 200, convs) +} + +type createConvReq struct { + Title string `json:"title"` +} + +func (s *Server) handleCreateConversation(w http.ResponseWriter, r *http.Request) { + u, _ := auth.UserFromContext(r.Context()) + var req createConvReq + _ = json.NewDecoder(r.Body).Decode(&req) + now := time.Now().Unix() + conv, err := s.Q.CreateConversation(r.Context(), store.CreateConversationParams{ + ID: uuid.NewString(), + UserID: u.ID, + Title: req.Title, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + writeErr(w, 500, "internal", err.Error()) + return + } + writeJSON(w, 200, conv) +} + +func (s *Server) handleGetConversation(w http.ResponseWriter, r *http.Request) { + u, _ := auth.UserFromContext(r.Context()) + id := chi.URLParam(r, "id") + conv, err := s.Q.GetConversation(r.Context(), store.GetConversationParams{ID: id, UserID: u.ID}) + if err != nil { + writeErr(w, 404, "not_found", "conversation not found") + return + } + writeJSON(w, 200, conv) +} + +func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) { + u, _ := auth.UserFromContext(r.Context()) + id := chi.URLParam(r, "id") + if _, err := s.Q.GetConversation(r.Context(), store.GetConversationParams{ID: id, UserID: u.ID}); err != nil { + writeErr(w, 404, "not_found", "conversation not found") + return + } + msgs, err := s.Q.ListMessagesForConversation(r.Context(), id) + if err != nil { + writeErr(w, 500, "internal", err.Error()) + return + } + writeJSON(w, 200, msgs) +} + +// handleChat is a stub for the streaming endpoint. The full implementation +// wires provider.Client → stream.Producer → DB and returns the stream id. +// See design/streaming.md. +func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { + u, _ := auth.UserFromContext(r.Context()) + if err := s.Ledger.CanStart(r.Context(), u.ID); err != nil { + writeErr(w, 402, "insufficient_funds", err.Error()) + return + } + writeErr(w, 501, "not_implemented", "chat streaming pending wiring") +} + +// handleStreamEvents serves SSE events for a given stream id, optionally +// resuming from ?after_seq=N. Stub: replay from DB only. +func (s *Server) handleStreamEvents(w http.ResponseWriter, r *http.Request) { + streamID := chi.URLParam(r, "id") + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(200) + flusher, _ := w.(http.Flusher) + + events, _ := stream.Replay(r.Context(), s.Q, streamID, 0) + for _, ev := range events { + b, _ := json.Marshal(ev) + _, _ = w.Write([]byte("data: ")) + _, _ = w.Write(b) + _, _ = w.Write([]byte("\n\n")) + if flusher != nil { + flusher.Flush() + } + } +} diff --git a/server/internal/auth/auth.go b/server/internal/auth/auth.go new file mode 100644 index 0000000..9ac1b4f --- /dev/null +++ b/server/internal/auth/auth.go @@ -0,0 +1,120 @@ +// Package auth handles session-cookie authentication. +// +// Sessions are opaque random tokens. Only their SHA-256 hash is persisted; +// the plaintext lives in the user's cookie. On lookup we hash, compare, and +// rotate the last_used timestamp. +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "time" + + "github.com/taciturnaxolotl/potluck/internal/store" +) + +const CookieName = "potluck_session" + +type ctxKey int + +const userKey ctxKey = 1 + +// Service exposes the auth surface that handlers depend on. +type Service struct { + q *store.Queries + ttl time.Duration +} + +func New(q *store.Queries, ttl time.Duration) *Service { + return &Service{q: q, ttl: ttl} +} + +// IssueSession mints a new session for userID and returns the plaintext +// token to set on the client cookie. +func (s *Service) IssueSession(ctx context.Context, userID string) (string, error) { + tok, err := randomToken(32) + if err != nil { + return "", err + } + now := time.Now().Unix() + _, err = s.q.CreateSession(ctx, store.CreateSessionParams{ + ID: hashToken(tok), + UserID: userID, + CreatedAt: now, + ExpiresAt: now + int64(s.ttl.Seconds()), + LastUsedAt: now, + }) + if err != nil { + return "", err + } + return tok, nil +} + +// Middleware loads the user from the session cookie and stashes it on the +// request context. Requests without a valid session pass through unauthenticated; +// it's up to the handler to require auth. +func (s *Service) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(CookieName) + if err != nil || c.Value == "" { + next.ServeHTTP(w, r) + return + } + sess, err := s.q.GetSession(r.Context(), store.GetSessionParams{ + ID: hashToken(c.Value), + ExpiresAt: time.Now().Unix(), + }) + if err != nil { + next.ServeHTTP(w, r) + return + } + _ = s.q.TouchSession(r.Context(), store.TouchSessionParams{ + LastUsedAt: time.Now().Unix(), + ID: sess.ID, + }) + u, err := s.q.GetUserByID(r.Context(), sess.UserID) + if err != nil { + next.ServeHTTP(w, r) + return + } + ctx := context.WithValue(r.Context(), userKey, &u) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// UserFromContext returns the authenticated user, if any. +func UserFromContext(ctx context.Context) (*store.User, bool) { + u, ok := ctx.Value(userKey).(*store.User) + return u, ok +} + +// Require is a middleware that 401s anonymous requests. +func Require(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, ok := UserFromContext(r.Context()); !ok { + http.Error(w, `{"error":{"code":"unauthenticated","message":"login required"}}`, http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func randomToken(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func hashToken(tok string) string { + sum := sha256.Sum256([]byte(tok)) + return hex.EncodeToString(sum[:]) +} + +// ErrNotFound is returned when a session lookup misses. +var ErrNotFound = errors.New("auth: session not found") diff --git a/server/internal/config/config.go b/server/internal/config/config.go new file mode 100644 index 0000000..21f4e9c --- /dev/null +++ b/server/internal/config/config.go @@ -0,0 +1,132 @@ +// Package config provides configuration options for the potluck server. +// +// a single Config struct with +// caarlos0/env tags drives everything; nested sub-structs use envPrefix +// to keep names readable; .env is autoloaded so dev needs zero ceremony; +// MustGet panics on parse error so a misconfigured boot fails loud. +// +// CONFIG.md is generated from this file. Re-run `task generate` after +// editing. +package config + +import ( + "github.com/caarlos0/env/v11" + + _ "github.com/joho/godotenv/autoload" // load .env at package init +) + +// Config wraps every server-level setting with sensible defaults. +// +//go:generate go run github.com/g4s8/envdoc@latest -output ../../../CONFIG.md +type Config struct { + // HTTP listen address. + HTTPListen string `env:"POTLUCK_HTTP_LISTEN_ADDR" envDefault:":8080"` + + // SQLite database path. Litestream replicates this file. + DatabaseURL string `env:"POTLUCK_DB" envDefault:"data/potluck.db"` + + // Environment the app is running in. Options: local or production. + Environment string `env:"POTLUCK_ENVIRONMENT" envDefault:"local"` + + // Public-facing base URL of the application. Used for OAuth redirects + // and email links once those land. + BaseURL string `env:"POTLUCK_BASE_URL" envDefault:"http://localhost:8080"` + + // Session secret signing the cookie store. Generate with: + // openssl rand -base64 32 + // Required in production; dev has no default to nudge you to set one. + SessionSecret string `env:"POTLUCK_SESSION_SECRET" envDefault:"dev-only-not-secret"` + + // Session cookie TTL. Idle sessions die after this many seconds of + // inactivity (the last_used_at column is bumped on every request). + SessionTTL int `env:"POTLUCK_SESSION_TTL" envDefault:"7776000"` // 90 days + + // Pioneer.ai inference credentials. + Pioneer Pioneer `envPrefix:"PIONEER_"` + + // Spend policy. + Spend SpendConfig `envPrefix:"POTLUCK_SPEND_"` + + // Litestream replication. + Litestream LitestreamConfig `envPrefix:"LITESTREAM_"` + + // Notifications via ntfy.sh. + Ntfy NtfyConfig `envPrefix:"NTFY_"` +} + +// Pioneer holds the upstream pioneer.ai inference credentials. +type Pioneer struct { + // API key. Required; the server refuses to start without it in production. + APIKey string `env:"API_KEY"` + + // Base URL — override for testing against the fake provider. + BaseURL string `env:"BASE_URL" envDefault:"https://api.pioneer.ai"` +} + +// Valid returns true if pioneer is configured. +func (p Pioneer) Valid() bool { return p.APIKey != "" } + +// SpendConfig holds the dollar-floor and concurrency policy that gates +// new streams. See design/accounting.md for rationale. +type SpendConfig struct { + // Minimum balance (USD micros) below which new streams are rejected. + // Default = $0.25 in micros. + MinBalanceToStartMicros int64 `env:"MIN_BALANCE_MICROS" envDefault:"250000"` + + // Maximum streams a single user can have in flight at once. + MaxConcurrentStreams int `env:"MAX_CONCURRENT_STREAMS" envDefault:"3"` +} + +// LitestreamConfig holds the Backblaze B2 replication target. +// +// Empty values disable replication; the server logs a warning and runs +// against a local-only DB. Useful for dev. +type LitestreamConfig struct { + // B2 bucket name. + B2Bucket string `env:"B2_BUCKET"` + + // B2 application key id. + B2KeyID string `env:"B2_KEY_ID"` + + // B2 application key. + B2ApplicationKey string `env:"B2_APPLICATION_KEY"` +} + +// Valid returns true if Litestream is configured. +func (l LitestreamConfig) Valid() bool { + return l.B2Bucket != "" && l.B2KeyID != "" && l.B2ApplicationKey != "" +} + +// NtfyConfig holds ntfy.sh notification settings. +type NtfyConfig struct { + // Topic name to publish to. + Topic string `env:"TOPIC"` + + // Bearer token for authenticated topics. + Token string `env:"TOKEN"` +} + +// Valid returns true if ntfy is configured. +func (n NtfyConfig) Valid() bool { return n.Topic != "" } + +// IsProduction reports whether this is a production build. +func (c Config) IsProduction() bool { return c.Environment == "production" } + +// IsLocal reports whether this is a local-dev build. +func (c Config) IsLocal() bool { return c.Environment == "local" } + +// MustGet parses Config from the environment or panics on missing/invalid +// values. Call this once at process start. +func MustGet() Config { + var c Config + if err := env.Parse(&c); err != nil { + panic("config: " + err.Error()) + } + if c.IsProduction() && !c.Pioneer.Valid() { + panic("config: PIONEER_API_KEY required in production") + } + if c.IsProduction() && c.SessionSecret == "dev-only-not-secret" { + panic("config: POTLUCK_SESSION_SECRET must be set in production") + } + return c +} diff --git a/server/internal/fakeprovider/fakeprovider.go b/server/internal/fakeprovider/fakeprovider.go new file mode 100644 index 0000000..3810ea7 --- /dev/null +++ b/server/internal/fakeprovider/fakeprovider.go @@ -0,0 +1,102 @@ +// Package fakeprovider is an in-process test double for pioneer.ai. +// +// It serves the OpenAI streaming chat-completions endpoint with a scripted +// sequence of chunks. Tests construct a *httptest.Server pointed at this +// handler and pass its URL as PIONEER_BASE_URL. +package fakeprovider + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "time" +) + +// Script is a sequence of SSE chunks the fake will emit in order. Use the +// helpers below to build common shapes. +type Script struct { + Chunks []string // raw `data: ...` payload lines (without the trailing \n\n) +} + +// DeltaChunk produces a chunk emitting a content delta. +func DeltaChunk(content string) string { + b, _ := json.Marshal(map[string]any{ + "id": "fake", + "model": "fake-model", + "choices": []map[string]any{ + {"delta": map[string]any{"content": content}, "index": 0}, + }, + }) + return string(b) +} + +// UsageChunk produces a final chunk that carries usage. +func UsageChunk(in, out int) string { + b, _ := json.Marshal(map[string]any{ + "id": "fake", + "model": "fake-model", + "choices": []map[string]any{{"delta": map[string]any{}, "index": 0, "finish_reason": "stop"}}, + "usage": map[string]any{ + "prompt_tokens": in, + "completion_tokens": out, + "total_tokens": in + out, + }, + }) + return string(b) +} + +// Server wraps httptest.Server and lets tests swap scripts between requests. +type Server struct { + HTTP *httptest.Server + + mu sync.Mutex + script Script +} + +func New() *Server { + s := &Server{} + s.HTTP = httptest.NewServer(http.HandlerFunc(s.handle)) + return s +} + +func (s *Server) Close() { s.HTTP.Close() } + +func (s *Server) URL() string { return s.HTTP.URL } + +// SetScript installs the chunks the next chat request will receive. +func (s *Server) SetScript(sc Script) { + s.mu.Lock() + defer s.mu.Unlock() + s.script = sc +} + +func (s *Server) handle(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/v1/chat/completions") { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.WriteHeader(http.StatusOK) + + flusher, _ := w.(http.Flusher) + + s.mu.Lock() + chunks := s.script.Chunks + s.mu.Unlock() + + for _, c := range chunks { + fmt.Fprintf(w, "data: %s\n\n", c) + if flusher != nil { + flusher.Flush() + } + time.Sleep(time.Millisecond) // exercise the streaming path + } + fmt.Fprint(w, "data: [DONE]\n\n") + if flusher != nil { + flusher.Flush() + } +} diff --git a/server/internal/ledger/ledger.go b/server/internal/ledger/ledger.go new file mode 100644 index 0000000..6dab96c --- /dev/null +++ b/server/internal/ledger/ledger.go @@ -0,0 +1,115 @@ +// Package ledger holds balance, contribution, and spend math. +// +// Money is int64 micros (see internal/money). Balance is recomputed on +// demand from sums; we don't keep a denormalised running total. With ~10 +// users this is fine. +package ledger + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/taciturnaxolotl/potluck/internal/money" + "github.com/taciturnaxolotl/potluck/internal/store" +) + +var ErrInsufficientFunds = errors.New("insufficient_funds") + +type Service struct { + q *store.Queries + + MinBalanceToStart money.Micros + MaxConcurrentStreams int +} + +func New(q *store.Queries, minBalance money.Micros, maxConcurrent int) *Service { + return &Service{q: q, MinBalanceToStart: minBalance, MaxConcurrentStreams: maxConcurrent} +} + +// Balance returns contributions − spends for a user. +func (s *Service) Balance(ctx context.Context, userID string) (money.Micros, error) { + contribs, err := s.q.SumContributions(ctx, userID) + if err != nil { + return 0, fmt.Errorf("ledger: sum contributions: %w", err) + } + spends, err := s.q.SumSpends(ctx, userID) + if err != nil { + return 0, fmt.Errorf("ledger: sum spends: %w", err) + } + return money.Micros(toInt64(contribs) - toInt64(spends)), nil +} + +// Contribute records a positive contribution. +func (s *Service) Contribute(ctx context.Context, userID string, amount money.Micros, note string) (store.Contribution, error) { + if amount <= 0 { + return store.Contribution{}, fmt.Errorf("ledger: contribution must be positive") + } + return s.q.CreateContribution(ctx, store.CreateContributionParams{ + ID: uuid.NewString(), + UserID: userID, + AmountMicros: int64(amount), + Note: note, + CreatedAt: time.Now().Unix(), + }) +} + +// CanStart returns nil if the user is allowed to begin a new stream right now, +// or ErrInsufficientFunds / a "too_many_streams" error. +func (s *Service) CanStart(ctx context.Context, userID string) error { + bal, err := s.Balance(ctx, userID) + if err != nil { + return err + } + if bal < s.MinBalanceToStart { + return ErrInsufficientFunds + } + active, err := s.q.CountActiveStreamsForUser(ctx, userID) + if err != nil { + return fmt.Errorf("ledger: count active: %w", err) + } + if int(active) >= s.MaxConcurrentStreams { + return errors.New("too_many_streams") + } + return nil +} + +// SettleStream records the final spend for a stream. Idempotent on stream_id. +func (s *Service) SettleStream(ctx context.Context, userID, streamID, model string, inputTokens, outputTokens int64, amount money.Micros, estimated bool) error { + est := int64(0) + if estimated { + est = 1 + } + _, err := s.q.UpsertSpend(ctx, store.UpsertSpendParams{ + ID: uuid.NewString(), + UserID: userID, + StreamID: streamID, + Model: model, + InputTokens: inputTokens, + OutputTokens: outputTokens, + AmountMicros: int64(amount), + IsEstimated: est, + CreatedAt: time.Now().Unix(), + }) + return err +} + +// toInt64 unboxes the interface{} sqlc emits for SUM/COALESCE aggregates. +func toInt64(v interface{}) int64 { + switch x := v.(type) { + case int64: + return x + case int: + return int64(x) + case float64: + // SQLite occasionally returns SUM as REAL; we never store fractions + // in the underlying column so a float here is a precision-safe int. + return int64(x) + case nil: + return 0 + } + return 0 +} diff --git a/server/internal/migrations/files/00001_init.sql b/server/internal/migrations/files/00001_init.sql new file mode 100644 index 0000000..6c3e1d4 --- /dev/null +++ b/server/internal/migrations/files/00001_init.sql @@ -0,0 +1,119 @@ +-- +goose Up +-- +goose StatementBegin + +-- All money columns are int64 micros (1 USD = 1_000_000). Never floats. + +CREATE TABLE users ( + id TEXT PRIMARY KEY, -- uuid + email TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, -- unix seconds + last_seen_at INTEGER +) STRICT; + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, -- opaque token (hashed) + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + last_used_at INTEGER NOT NULL +) STRICT; +CREATE INDEX sessions_by_user ON sessions(user_id); + +-- Conversations and messages. +CREATE TABLE conversations ( + id TEXT PRIMARY KEY, -- uuid + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + archived_at INTEGER +) STRICT; +CREATE INDEX conversations_by_user ON conversations(user_id, updated_at DESC); + +CREATE TABLE messages ( + id TEXT PRIMARY KEY, -- server uuid + conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + client_id TEXT, -- idempotency key from client (uuidv7) + role TEXT NOT NULL, -- 'user' | 'assistant' | 'system' | 'tool' + content TEXT NOT NULL DEFAULT '', + model TEXT, -- provider model id (assistant only) + created_at INTEGER NOT NULL, + UNIQUE(conversation_id, client_id) +) STRICT; +CREATE INDEX messages_by_conversation ON messages(conversation_id, created_at); + +-- Streams: one per assistant generation. Chunks fan out from a stream. +CREATE TABLE streams ( + id TEXT PRIMARY KEY, -- uuid + conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + assistant_message_id TEXT REFERENCES messages(id), + idempotency_key TEXT NOT NULL, -- client supplies on POST /api/chat + model TEXT NOT NULL, + status TEXT NOT NULL, -- 'pending' | 'running' | 'done' | 'error' | 'canceled' + error_code TEXT, + error_message TEXT, + started_at INTEGER NOT NULL, + finished_at INTEGER, + UNIQUE(user_id, idempotency_key) +) STRICT; +CREATE INDEX streams_by_user ON streams(user_id, started_at DESC); + +CREATE TABLE stream_chunks ( + stream_id TEXT NOT NULL REFERENCES streams(id) ON DELETE CASCADE, + seq INTEGER NOT NULL, -- 1-based monotonic + event TEXT NOT NULL, -- 'delta' | 'usage' | 'error' | 'done' + data TEXT NOT NULL, -- JSON payload + created_at INTEGER NOT NULL, + PRIMARY KEY (stream_id, seq) +) STRICT, WITHOUT ROWID; + +-- Ledger. +-- contributions: positive amounts. spends: positive amounts charged against a stream. +-- Balance = sum(contributions.amount) - sum(spends.amount). +CREATE TABLE contributions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + amount_micros INTEGER NOT NULL CHECK (amount_micros > 0), + note TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL +) STRICT; +CREATE INDEX contributions_by_user ON contributions(user_id, created_at DESC); + +CREATE TABLE spends ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + stream_id TEXT NOT NULL REFERENCES streams(id) ON DELETE CASCADE, + model TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + amount_micros INTEGER NOT NULL CHECK (amount_micros >= 0), + is_estimated INTEGER NOT NULL DEFAULT 0, -- 1 if usage chunk missing; reconcile job fixes + created_at INTEGER NOT NULL, + UNIQUE(stream_id) +) STRICT; +CREATE INDEX spends_by_user ON spends(user_id, created_at DESC); + +-- Provider model price book. amount_micros_per_1k_tokens, refreshed manually. +CREATE TABLE model_prices ( + model TEXT PRIMARY KEY, + input_micros_per_1k INTEGER NOT NULL, + output_micros_per_1k INTEGER NOT NULL, + updated_at INTEGER NOT NULL +) STRICT; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS model_prices; +DROP TABLE IF EXISTS spends; +DROP TABLE IF EXISTS contributions; +DROP TABLE IF EXISTS stream_chunks; +DROP TABLE IF EXISTS streams; +DROP TABLE IF EXISTS messages; +DROP TABLE IF EXISTS conversations; +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS users; +-- +goose StatementEnd diff --git a/server/internal/migrations/migrations.go b/server/internal/migrations/migrations.go new file mode 100644 index 0000000..f98948a --- /dev/null +++ b/server/internal/migrations/migrations.go @@ -0,0 +1,30 @@ +// Package migrations embeds and runs goose migrations. +// +// The single source of truth for schema lives in server/db/migrations. +// This package embeds that directory and exposes Run(db) for the server +// boot path. The same .sql files are also used by the goose CLI via +// `task migrate-*`. +package migrations + +import ( + "database/sql" + "embed" + "fmt" + + "github.com/pressly/goose/v3" +) + +//go:embed all:files +var FS embed.FS + +// Run applies all pending up-migrations. +func Run(db *sql.DB) error { + goose.SetBaseFS(FS) + if err := goose.SetDialect("sqlite3"); err != nil { + return fmt.Errorf("goose dialect: %w", err) + } + if err := goose.Up(db, "files"); err != nil { + return fmt.Errorf("goose up: %w", err) + } + return nil +} diff --git a/server/internal/money/money.go b/server/internal/money/money.go new file mode 100644 index 0000000..d8ce07b --- /dev/null +++ b/server/internal/money/money.go @@ -0,0 +1,113 @@ +// Package money handles all monetary values as int64 micros. +// +// 1 USD = 1,000,000 micros. Floats anywhere in the spend path are a bug. +// All ledger math, balance checks, and provider pricing live in this unit. +package money + +import ( + "fmt" + "strconv" + "strings" +) + +// Micros is a signed amount of USD denominated in millionths. +// +// 1 USD = 1_000_000 micros +// 1 cent = 10_000 micros +// 1 mill = 1_000 micros +// 1 micro = 1 micros +type Micros int64 + +const ( + PerUSD Micros = 1_000_000 + PerCent Micros = 10_000 +) + +// FromUSD converts a whole-USD integer to micros. Use this only for constants +// and config defaults; never for ingesting values that originated as floats. +func FromUSD(usd int64) Micros { return Micros(usd) * PerUSD } + +// FromCents converts an integer cent count to micros. +func FromCents(cents int64) Micros { return Micros(cents) * PerCent } + +// ParseUSD parses a decimal USD string ("1.23", "0.000125") into micros. +// Up to 6 fractional digits are preserved; anything beyond is truncated. +// Returns an error on malformed input. Never goes through float64. +func ParseUSD(s string) (Micros, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("money: empty string") + } + neg := false + switch s[0] { + case '-': + neg = true + s = s[1:] + case '+': + s = s[1:] + } + intPart, fracPart, hasFrac := s, "", false + if i := strings.IndexByte(s, '.'); i >= 0 { + intPart, fracPart, hasFrac = s[:i], s[i+1:], true + } + if intPart == "" && !hasFrac { + return 0, fmt.Errorf("money: %q is not a number", s) + } + if intPart == "" { + intPart = "0" + } + // After sign stripping, no further sign is allowed in either part. + if intPart[0] == '+' || intPart[0] == '-' { + return 0, fmt.Errorf("money: stray sign in %q", s) + } + whole, err := strconv.ParseInt(intPart, 10, 64) + if err != nil { + return 0, fmt.Errorf("money: bad integer part %q: %w", intPart, err) + } + var frac int64 + if hasFrac { + // pad/truncate to exactly 6 digits + switch { + case len(fracPart) < 6: + fracPart += strings.Repeat("0", 6-len(fracPart)) + case len(fracPart) > 6: + fracPart = fracPart[:6] + } + frac, err = strconv.ParseInt(fracPart, 10, 64) + if err != nil { + return 0, fmt.Errorf("money: bad fractional part: %w", err) + } + } + v := whole*int64(PerUSD) + frac + if neg { + v = -v + } + return Micros(v), nil +} + +// USDString formats a Micros amount as a decimal USD string with up to 6 +// fractional digits, trimming trailing zeros after the cent place. Always +// includes at least cents ("1.00", "0.000125", "-0.50"). +func (m Micros) USDString() string { + neg := m < 0 + v := m + if neg { + v = -v + } + whole := int64(v) / int64(PerUSD) + frac := int64(v) % int64(PerUSD) + out := fmt.Sprintf("%d.%06d", whole, frac) + // Trim trailing zeros below the cent place but keep at least 2 digits. + dot := strings.IndexByte(out, '.') + end := len(out) + for end > dot+3 && out[end-1] == '0' { + end-- + } + out = out[:end] + if neg { + out = "-" + out + } + return out +} + +func (m Micros) String() string { return "$" + m.USDString() } diff --git a/server/internal/money/money_test.go b/server/internal/money/money_test.go new file mode 100644 index 0000000..6ea4a9c --- /dev/null +++ b/server/internal/money/money_test.go @@ -0,0 +1,55 @@ +package money + +import "testing" + +func TestParseUSD(t *testing.T) { + cases := []struct { + in string + want Micros + }{ + {"0", 0}, + {"1", FromUSD(1)}, + {"1.23", FromCents(123)}, + {"0.000125", 125}, + {"-0.50", -FromCents(50)}, + {"+2.5", FromCents(250)}, + {".25", FromCents(25)}, + {"1.2345678", FromCents(123) + 4567}, // truncates to 6 digits + } + for _, c := range cases { + got, err := ParseUSD(c.in) + if err != nil { + t.Fatalf("ParseUSD(%q): %v", c.in, err) + } + if got != c.want { + t.Errorf("ParseUSD(%q) = %d, want %d", c.in, got, c.want) + } + } +} + +func TestParseUSDErrors(t *testing.T) { + for _, in := range []string{"", "abc", "1.2.3", "--1"} { + if _, err := ParseUSD(in); err == nil { + t.Errorf("ParseUSD(%q) expected error", in) + } + } +} + +func TestUSDString(t *testing.T) { + cases := []struct { + in Micros + want string + }{ + {0, "0.00"}, + {FromUSD(1), "1.00"}, + {FromCents(123), "1.23"}, + {125, "0.000125"}, + {-FromCents(50), "-0.50"}, + {FromCents(123) + 4567, "1.234567"}, + } + for _, c := range cases { + if got := c.in.USDString(); got != c.want { + t.Errorf("Micros(%d).USDString() = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/server/internal/provider/pioneer.go b/server/internal/provider/pioneer.go new file mode 100644 index 0000000..8be5e30 --- /dev/null +++ b/server/internal/provider/pioneer.go @@ -0,0 +1,156 @@ +// Package provider talks to pioneer.ai. +// +// pioneer.ai is OpenAI-compatible. This package speaks the smallest dialect +// the rest of potluck needs: streamed chat completions with usage at the end. +// Add quirks here as they're discovered, alongside fixtures in +// internal/fakeprovider. +package provider + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +type Client struct { + BaseURL string + APIKey string + HTTP *http.Client +} + +func New(baseURL, apiKey string) *Client { + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + APIKey: apiKey, + HTTP: &http.Client{}, + } +} + +// ChatRequest is the subset of the OpenAI chat-completions body we send. +type ChatRequest struct { + Model string `json:"model"` + Messages []ChatMessage `json:"messages"` + Stream bool `json:"stream"` + StreamOptions *StreamOpts `json:"stream_options,omitempty"` +} + +type StreamOpts struct { + IncludeUsage bool `json:"include_usage"` +} + +type ChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// Chunk is one decoded SSE event from the provider. +type Chunk struct { + Raw []byte // the verbatim JSON for re-emission to clients + Delta string // assistant content delta, if any + Usage *Usage // populated on the final chunk when include_usage is on + Done bool // true once we see "[DONE]" + Extra map[string]any // anything else we care to inspect later +} + +type Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// StreamChat opens a streaming chat completion. The caller drives it via the +// returned channel; cancelling ctx aborts the upstream connection. +// +// Important: the producer goroutine that uses this client should run on +// context.Background()-derived contexts so a client disconnect never +// cancels the upstream call. See AGENTS.md "Streaming". +func (c *Client) StreamChat(ctx context.Context, req ChatRequest) (<-chan Chunk, <-chan error, error) { + req.Stream = true + if req.StreamOptions == nil { + req.StreamOptions = &StreamOpts{IncludeUsage: true} + } + body, err := json.Marshal(req) + if err != nil { + return nil, nil, err + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/v1/chat/completions", bytes.NewReader(body)) + if err != nil { + return nil, nil, err + } + httpReq.Header.Set("Authorization", "Bearer "+c.APIKey) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "text/event-stream") + + resp, err := c.HTTP.Do(httpReq) + if err != nil { + return nil, nil, err + } + if resp.StatusCode/100 != 2 { + b, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + return nil, nil, fmt.Errorf("provider: %s: %s", resp.Status, b) + } + + chunks := make(chan Chunk, 32) + errs := make(chan error, 1) + + go func() { + defer close(chunks) + defer close(errs) + defer resp.Body.Close() + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + for scanner.Scan() { + line := scanner.Bytes() + if !bytes.HasPrefix(line, []byte("data:")) { + continue + } + payload := bytes.TrimSpace(line[len("data:"):]) + if bytes.Equal(payload, []byte("[DONE]")) { + chunks <- Chunk{Done: true} + return + } + ch, err := decodeChunk(payload) + if err != nil { + errs <- err + return + } + chunks <- ch + } + if err := scanner.Err(); err != nil { + errs <- err + } + }() + + return chunks, errs, nil +} + +// minimal OpenAI-shaped chunk for decoding +type rawChunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + FinishReason *string `json:"finish_reason,omitempty"` + } `json:"choices"` + Usage *Usage `json:"usage,omitempty"` +} + +func decodeChunk(payload []byte) (Chunk, error) { + var rc rawChunk + if err := json.Unmarshal(payload, &rc); err != nil { + return Chunk{}, fmt.Errorf("provider: decode chunk: %w", err) + } + c := Chunk{Raw: append([]byte(nil), payload...), Usage: rc.Usage} + if len(rc.Choices) > 0 { + c.Delta = rc.Choices[0].Delta.Content + } + return c, nil +} diff --git a/server/internal/store/conversations.sql.go b/server/internal/store/conversations.sql.go new file mode 100644 index 0000000..460b587 --- /dev/null +++ b/server/internal/store/conversations.sql.go @@ -0,0 +1,161 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: conversations.sql + +package store + +import ( + "context" + "database/sql" +) + +const archiveConversation = `-- name: ArchiveConversation :exec +UPDATE conversations SET archived_at = ? WHERE id = ? AND user_id = ? +` + +type ArchiveConversationParams struct { + ArchivedAt sql.NullInt64 `json:"archived_at"` + ID string `json:"id"` + UserID string `json:"user_id"` +} + +func (q *Queries) ArchiveConversation(ctx context.Context, arg ArchiveConversationParams) error { + _, err := q.db.ExecContext(ctx, archiveConversation, arg.ArchivedAt, arg.ID, arg.UserID) + return err +} + +const createConversation = `-- name: CreateConversation :one +INSERT INTO conversations (id, user_id, title, created_at, updated_at) +VALUES (?, ?, ?, ?, ?) +RETURNING id, user_id, title, created_at, updated_at, archived_at +` + +type CreateConversationParams struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Title string `json:"title"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +func (q *Queries) CreateConversation(ctx context.Context, arg CreateConversationParams) (Conversation, error) { + row := q.db.QueryRowContext(ctx, createConversation, + arg.ID, + arg.UserID, + arg.Title, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i Conversation + err := row.Scan( + &i.ID, + &i.UserID, + &i.Title, + &i.CreatedAt, + &i.UpdatedAt, + &i.ArchivedAt, + ) + return i, err +} + +const getConversation = `-- name: GetConversation :one +SELECT id, user_id, title, created_at, updated_at, archived_at FROM conversations WHERE id = ? AND user_id = ? +` + +type GetConversationParams struct { + ID string `json:"id"` + UserID string `json:"user_id"` +} + +func (q *Queries) GetConversation(ctx context.Context, arg GetConversationParams) (Conversation, error) { + row := q.db.QueryRowContext(ctx, getConversation, arg.ID, arg.UserID) + var i Conversation + err := row.Scan( + &i.ID, + &i.UserID, + &i.Title, + &i.CreatedAt, + &i.UpdatedAt, + &i.ArchivedAt, + ) + return i, err +} + +const listConversationsForUser = `-- name: ListConversationsForUser :many +SELECT id, user_id, title, created_at, updated_at, archived_at FROM conversations +WHERE user_id = ? AND archived_at IS NULL +ORDER BY updated_at DESC +LIMIT ? +` + +type ListConversationsForUserParams struct { + UserID string `json:"user_id"` + Limit int64 `json:"limit"` +} + +func (q *Queries) ListConversationsForUser(ctx context.Context, arg ListConversationsForUserParams) ([]Conversation, error) { + rows, err := q.db.QueryContext(ctx, listConversationsForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Conversation{} + for rows.Next() { + var i Conversation + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Title, + &i.CreatedAt, + &i.UpdatedAt, + &i.ArchivedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const touchConversation = `-- name: TouchConversation :exec +UPDATE conversations SET updated_at = ? WHERE id = ? +` + +type TouchConversationParams struct { + UpdatedAt int64 `json:"updated_at"` + ID string `json:"id"` +} + +func (q *Queries) TouchConversation(ctx context.Context, arg TouchConversationParams) error { + _, err := q.db.ExecContext(ctx, touchConversation, arg.UpdatedAt, arg.ID) + return err +} + +const updateConversationTitle = `-- name: UpdateConversationTitle :exec +UPDATE conversations SET title = ?, updated_at = ? +WHERE id = ? AND user_id = ? +` + +type UpdateConversationTitleParams struct { + Title string `json:"title"` + UpdatedAt int64 `json:"updated_at"` + ID string `json:"id"` + UserID string `json:"user_id"` +} + +func (q *Queries) UpdateConversationTitle(ctx context.Context, arg UpdateConversationTitleParams) error { + _, err := q.db.ExecContext(ctx, updateConversationTitle, + arg.Title, + arg.UpdatedAt, + arg.ID, + arg.UserID, + ) + return err +} diff --git a/server/internal/store/db.go b/server/internal/store/db.go new file mode 100644 index 0000000..5131a7c --- /dev/null +++ b/server/internal/store/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package store + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/server/internal/store/ledger.sql.go b/server/internal/store/ledger.sql.go new file mode 100644 index 0000000..b84811a --- /dev/null +++ b/server/internal/store/ledger.sql.go @@ -0,0 +1,195 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: ledger.sql + +package store + +import ( + "context" +) + +const createContribution = `-- name: CreateContribution :one +INSERT INTO contributions (id, user_id, amount_micros, note, created_at) +VALUES (?, ?, ?, ?, ?) +RETURNING id, user_id, amount_micros, note, created_at +` + +type CreateContributionParams struct { + ID string `json:"id"` + UserID string `json:"user_id"` + AmountMicros int64 `json:"amount_micros"` + Note string `json:"note"` + CreatedAt int64 `json:"created_at"` +} + +func (q *Queries) CreateContribution(ctx context.Context, arg CreateContributionParams) (Contribution, error) { + row := q.db.QueryRowContext(ctx, createContribution, + arg.ID, + arg.UserID, + arg.AmountMicros, + arg.Note, + arg.CreatedAt, + ) + var i Contribution + err := row.Scan( + &i.ID, + &i.UserID, + &i.AmountMicros, + &i.Note, + &i.CreatedAt, + ) + return i, err +} + +const listContributionsForUser = `-- name: ListContributionsForUser :many +SELECT id, user_id, amount_micros, note, created_at FROM contributions +WHERE user_id = ? +ORDER BY created_at DESC +LIMIT ? +` + +type ListContributionsForUserParams struct { + UserID string `json:"user_id"` + Limit int64 `json:"limit"` +} + +func (q *Queries) ListContributionsForUser(ctx context.Context, arg ListContributionsForUserParams) ([]Contribution, error) { + rows, err := q.db.QueryContext(ctx, listContributionsForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Contribution{} + for rows.Next() { + var i Contribution + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.AmountMicros, + &i.Note, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listEstimatedSpends = `-- name: ListEstimatedSpends :many +SELECT id, user_id, stream_id, model, input_tokens, output_tokens, amount_micros, is_estimated, created_at FROM spends WHERE is_estimated = 1 ORDER BY created_at ASC LIMIT ? +` + +func (q *Queries) ListEstimatedSpends(ctx context.Context, limit int64) ([]Spend, error) { + rows, err := q.db.QueryContext(ctx, listEstimatedSpends, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Spend{} + for rows.Next() { + var i Spend + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.StreamID, + &i.Model, + &i.InputTokens, + &i.OutputTokens, + &i.AmountMicros, + &i.IsEstimated, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const sumContributions = `-- name: SumContributions :one +SELECT COALESCE(SUM(amount_micros), 0) FROM contributions WHERE user_id = ? +` + +func (q *Queries) SumContributions(ctx context.Context, userID string) (interface{}, error) { + row := q.db.QueryRowContext(ctx, sumContributions, userID) + var coalesce interface{} + err := row.Scan(&coalesce) + return coalesce, err +} + +const sumSpends = `-- name: SumSpends :one +SELECT COALESCE(SUM(amount_micros), 0) FROM spends WHERE user_id = ? +` + +func (q *Queries) SumSpends(ctx context.Context, userID string) (interface{}, error) { + row := q.db.QueryRowContext(ctx, sumSpends, userID) + var coalesce interface{} + err := row.Scan(&coalesce) + return coalesce, err +} + +const upsertSpend = `-- name: UpsertSpend :one +INSERT INTO spends ( + id, user_id, stream_id, model, input_tokens, output_tokens, + amount_micros, is_estimated, created_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(stream_id) DO UPDATE SET + input_tokens = excluded.input_tokens, + output_tokens = excluded.output_tokens, + amount_micros = excluded.amount_micros, + is_estimated = excluded.is_estimated +RETURNING id, user_id, stream_id, model, input_tokens, output_tokens, amount_micros, is_estimated, created_at +` + +type UpsertSpendParams struct { + ID string `json:"id"` + UserID string `json:"user_id"` + StreamID string `json:"stream_id"` + Model string `json:"model"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + AmountMicros int64 `json:"amount_micros"` + IsEstimated int64 `json:"is_estimated"` + CreatedAt int64 `json:"created_at"` +} + +func (q *Queries) UpsertSpend(ctx context.Context, arg UpsertSpendParams) (Spend, error) { + row := q.db.QueryRowContext(ctx, upsertSpend, + arg.ID, + arg.UserID, + arg.StreamID, + arg.Model, + arg.InputTokens, + arg.OutputTokens, + arg.AmountMicros, + arg.IsEstimated, + arg.CreatedAt, + ) + var i Spend + err := row.Scan( + &i.ID, + &i.UserID, + &i.StreamID, + &i.Model, + &i.InputTokens, + &i.OutputTokens, + &i.AmountMicros, + &i.IsEstimated, + &i.CreatedAt, + ) + return i, err +} diff --git a/server/internal/store/messages.sql.go b/server/internal/store/messages.sql.go new file mode 100644 index 0000000..ad9b65d --- /dev/null +++ b/server/internal/store/messages.sql.go @@ -0,0 +1,122 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: messages.sql + +package store + +import ( + "context" + "database/sql" +) + +const appendAssistantContent = `-- name: AppendAssistantContent :exec +UPDATE messages SET content = content || ? WHERE id = ? +` + +type AppendAssistantContentParams struct { + Content string `json:"content"` + ID string `json:"id"` +} + +func (q *Queries) AppendAssistantContent(ctx context.Context, arg AppendAssistantContentParams) error { + _, err := q.db.ExecContext(ctx, appendAssistantContent, arg.Content, arg.ID) + return err +} + +const getMessage = `-- name: GetMessage :one +SELECT id, conversation_id, client_id, role, content, model, created_at FROM messages WHERE id = ? +` + +func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) { + row := q.db.QueryRowContext(ctx, getMessage, id) + var i Message + err := row.Scan( + &i.ID, + &i.ConversationID, + &i.ClientID, + &i.Role, + &i.Content, + &i.Model, + &i.CreatedAt, + ) + return i, err +} + +const listMessagesForConversation = `-- name: ListMessagesForConversation :many +SELECT id, conversation_id, client_id, role, content, model, created_at FROM messages +WHERE conversation_id = ? +ORDER BY created_at ASC, id ASC +` + +func (q *Queries) ListMessagesForConversation(ctx context.Context, conversationID string) ([]Message, error) { + rows, err := q.db.QueryContext(ctx, listMessagesForConversation, conversationID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Message{} + for rows.Next() { + var i Message + if err := rows.Scan( + &i.ID, + &i.ConversationID, + &i.ClientID, + &i.Role, + &i.Content, + &i.Model, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertMessage = `-- name: UpsertMessage :one +INSERT INTO messages (id, conversation_id, client_id, role, content, model, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(conversation_id, client_id) DO UPDATE SET + content = excluded.content +RETURNING id, conversation_id, client_id, role, content, model, created_at +` + +type UpsertMessageParams struct { + ID string `json:"id"` + ConversationID string `json:"conversation_id"` + ClientID sql.NullString `json:"client_id"` + Role string `json:"role"` + Content string `json:"content"` + Model sql.NullString `json:"model"` + CreatedAt int64 `json:"created_at"` +} + +func (q *Queries) UpsertMessage(ctx context.Context, arg UpsertMessageParams) (Message, error) { + row := q.db.QueryRowContext(ctx, upsertMessage, + arg.ID, + arg.ConversationID, + arg.ClientID, + arg.Role, + arg.Content, + arg.Model, + arg.CreatedAt, + ) + var i Message + err := row.Scan( + &i.ID, + &i.ConversationID, + &i.ClientID, + &i.Role, + &i.Content, + &i.Model, + &i.CreatedAt, + ) + return i, err +} diff --git a/server/internal/store/models.go b/server/internal/store/models.go new file mode 100644 index 0000000..1026389 --- /dev/null +++ b/server/internal/store/models.go @@ -0,0 +1,93 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package store + +import ( + "database/sql" +) + +type Contribution struct { + ID string `json:"id"` + UserID string `json:"user_id"` + AmountMicros int64 `json:"amount_micros"` + Note string `json:"note"` + CreatedAt int64 `json:"created_at"` +} + +type Conversation struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Title string `json:"title"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + ArchivedAt sql.NullInt64 `json:"archived_at"` +} + +type Message struct { + ID string `json:"id"` + ConversationID string `json:"conversation_id"` + ClientID sql.NullString `json:"client_id"` + Role string `json:"role"` + Content string `json:"content"` + Model sql.NullString `json:"model"` + CreatedAt int64 `json:"created_at"` +} + +type ModelPrice struct { + Model string `json:"model"` + InputMicrosPer1k int64 `json:"input_micros_per_1k"` + OutputMicrosPer1k int64 `json:"output_micros_per_1k"` + UpdatedAt int64 `json:"updated_at"` +} + +type Session struct { + ID string `json:"id"` + UserID string `json:"user_id"` + CreatedAt int64 `json:"created_at"` + ExpiresAt int64 `json:"expires_at"` + LastUsedAt int64 `json:"last_used_at"` +} + +type Spend struct { + ID string `json:"id"` + UserID string `json:"user_id"` + StreamID string `json:"stream_id"` + Model string `json:"model"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + AmountMicros int64 `json:"amount_micros"` + IsEstimated int64 `json:"is_estimated"` + CreatedAt int64 `json:"created_at"` +} + +type Stream struct { + ID string `json:"id"` + ConversationID string `json:"conversation_id"` + UserID string `json:"user_id"` + AssistantMessageID sql.NullString `json:"assistant_message_id"` + IdempotencyKey string `json:"idempotency_key"` + Model string `json:"model"` + Status string `json:"status"` + ErrorCode sql.NullString `json:"error_code"` + ErrorMessage sql.NullString `json:"error_message"` + StartedAt int64 `json:"started_at"` + FinishedAt sql.NullInt64 `json:"finished_at"` +} + +type StreamChunk struct { + StreamID string `json:"stream_id"` + Seq int64 `json:"seq"` + Event string `json:"event"` + Data string `json:"data"` + CreatedAt int64 `json:"created_at"` +} + +type User struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + CreatedAt int64 `json:"created_at"` + LastSeenAt sql.NullInt64 `json:"last_seen_at"` +} diff --git a/server/internal/store/prices.sql.go b/server/internal/store/prices.sql.go new file mode 100644 index 0000000..6fed193 --- /dev/null +++ b/server/internal/store/prices.sql.go @@ -0,0 +1,84 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: prices.sql + +package store + +import ( + "context" +) + +const getModelPrice = `-- name: GetModelPrice :one +SELECT model, input_micros_per_1k, output_micros_per_1k, updated_at FROM model_prices WHERE model = ? +` + +func (q *Queries) GetModelPrice(ctx context.Context, model string) (ModelPrice, error) { + row := q.db.QueryRowContext(ctx, getModelPrice, model) + var i ModelPrice + err := row.Scan( + &i.Model, + &i.InputMicrosPer1k, + &i.OutputMicrosPer1k, + &i.UpdatedAt, + ) + return i, err +} + +const listModelPrices = `-- name: ListModelPrices :many +SELECT model, input_micros_per_1k, output_micros_per_1k, updated_at FROM model_prices ORDER BY model ASC +` + +func (q *Queries) ListModelPrices(ctx context.Context) ([]ModelPrice, error) { + rows, err := q.db.QueryContext(ctx, listModelPrices) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ModelPrice{} + for rows.Next() { + var i ModelPrice + if err := rows.Scan( + &i.Model, + &i.InputMicrosPer1k, + &i.OutputMicrosPer1k, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertModelPrice = `-- name: UpsertModelPrice :exec +INSERT INTO model_prices (model, input_micros_per_1k, output_micros_per_1k, updated_at) +VALUES (?, ?, ?, ?) +ON CONFLICT(model) DO UPDATE SET + input_micros_per_1k = excluded.input_micros_per_1k, + output_micros_per_1k = excluded.output_micros_per_1k, + updated_at = excluded.updated_at +` + +type UpsertModelPriceParams struct { + Model string `json:"model"` + InputMicrosPer1k int64 `json:"input_micros_per_1k"` + OutputMicrosPer1k int64 `json:"output_micros_per_1k"` + UpdatedAt int64 `json:"updated_at"` +} + +func (q *Queries) UpsertModelPrice(ctx context.Context, arg UpsertModelPriceParams) error { + _, err := q.db.ExecContext(ctx, upsertModelPrice, + arg.Model, + arg.InputMicrosPer1k, + arg.OutputMicrosPer1k, + arg.UpdatedAt, + ) + return err +} diff --git a/server/internal/store/querier.go b/server/internal/store/querier.go new file mode 100644 index 0000000..f712cb7 --- /dev/null +++ b/server/internal/store/querier.go @@ -0,0 +1,50 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package store + +import ( + "context" +) + +type Querier interface { + AppendAssistantContent(ctx context.Context, arg AppendAssistantContentParams) error + AppendStreamChunk(ctx context.Context, arg AppendStreamChunkParams) error + ArchiveConversation(ctx context.Context, arg ArchiveConversationParams) error + CountActiveStreamsForUser(ctx context.Context, userID string) (int64, error) + CreateContribution(ctx context.Context, arg CreateContributionParams) (Contribution, error) + CreateConversation(ctx context.Context, arg CreateConversationParams) (Conversation, error) + CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) + CreateStream(ctx context.Context, arg CreateStreamParams) (Stream, error) + CreateUser(ctx context.Context, arg CreateUserParams) (User, error) + DeleteExpiredSessions(ctx context.Context, expiresAt int64) error + DeleteSession(ctx context.Context, id string) error + GetConversation(ctx context.Context, arg GetConversationParams) (Conversation, error) + GetMessage(ctx context.Context, id string) (Message, error) + GetModelPrice(ctx context.Context, model string) (ModelPrice, error) + GetSession(ctx context.Context, arg GetSessionParams) (Session, error) + GetStream(ctx context.Context, id string) (Stream, error) + GetStreamByIdempotencyKey(ctx context.Context, arg GetStreamByIdempotencyKeyParams) (Stream, error) + GetUserByEmail(ctx context.Context, email string) (User, error) + GetUserByID(ctx context.Context, id string) (User, error) + ListContributionsForUser(ctx context.Context, arg ListContributionsForUserParams) ([]Contribution, error) + ListConversationsForUser(ctx context.Context, arg ListConversationsForUserParams) ([]Conversation, error) + ListEstimatedSpends(ctx context.Context, limit int64) ([]Spend, error) + ListMessagesForConversation(ctx context.Context, conversationID string) ([]Message, error) + ListModelPrices(ctx context.Context) ([]ModelPrice, error) + ListStreamChunksAfter(ctx context.Context, arg ListStreamChunksAfterParams) ([]ListStreamChunksAfterRow, error) + MaxStreamChunkSeq(ctx context.Context, streamID string) (interface{}, error) + SetStreamStatus(ctx context.Context, arg SetStreamStatusParams) error + SumContributions(ctx context.Context, userID string) (interface{}, error) + SumSpends(ctx context.Context, userID string) (interface{}, error) + TouchConversation(ctx context.Context, arg TouchConversationParams) error + TouchSession(ctx context.Context, arg TouchSessionParams) error + TouchUser(ctx context.Context, arg TouchUserParams) error + UpdateConversationTitle(ctx context.Context, arg UpdateConversationTitleParams) error + UpsertMessage(ctx context.Context, arg UpsertMessageParams) (Message, error) + UpsertModelPrice(ctx context.Context, arg UpsertModelPriceParams) error + UpsertSpend(ctx context.Context, arg UpsertSpendParams) (Spend, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/server/internal/store/sessions.sql.go b/server/internal/store/sessions.sql.go new file mode 100644 index 0000000..1f71cf3 --- /dev/null +++ b/server/internal/store/sessions.sql.go @@ -0,0 +1,97 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: sessions.sql + +package store + +import ( + "context" +) + +const createSession = `-- name: CreateSession :one +INSERT INTO sessions (id, user_id, created_at, expires_at, last_used_at) +VALUES (?, ?, ?, ?, ?) +RETURNING id, user_id, created_at, expires_at, last_used_at +` + +type CreateSessionParams struct { + ID string `json:"id"` + UserID string `json:"user_id"` + CreatedAt int64 `json:"created_at"` + ExpiresAt int64 `json:"expires_at"` + LastUsedAt int64 `json:"last_used_at"` +} + +func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) { + row := q.db.QueryRowContext(ctx, createSession, + arg.ID, + arg.UserID, + arg.CreatedAt, + arg.ExpiresAt, + arg.LastUsedAt, + ) + var i Session + err := row.Scan( + &i.ID, + &i.UserID, + &i.CreatedAt, + &i.ExpiresAt, + &i.LastUsedAt, + ) + return i, err +} + +const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec +DELETE FROM sessions WHERE expires_at <= ? +` + +func (q *Queries) DeleteExpiredSessions(ctx context.Context, expiresAt int64) error { + _, err := q.db.ExecContext(ctx, deleteExpiredSessions, expiresAt) + return err +} + +const deleteSession = `-- name: DeleteSession :exec +DELETE FROM sessions WHERE id = ? +` + +func (q *Queries) DeleteSession(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteSession, id) + return err +} + +const getSession = `-- name: GetSession :one +SELECT id, user_id, created_at, expires_at, last_used_at FROM sessions WHERE id = ? AND expires_at > ? +` + +type GetSessionParams struct { + ID string `json:"id"` + ExpiresAt int64 `json:"expires_at"` +} + +func (q *Queries) GetSession(ctx context.Context, arg GetSessionParams) (Session, error) { + row := q.db.QueryRowContext(ctx, getSession, arg.ID, arg.ExpiresAt) + var i Session + err := row.Scan( + &i.ID, + &i.UserID, + &i.CreatedAt, + &i.ExpiresAt, + &i.LastUsedAt, + ) + return i, err +} + +const touchSession = `-- name: TouchSession :exec +UPDATE sessions SET last_used_at = ? WHERE id = ? +` + +type TouchSessionParams struct { + LastUsedAt int64 `json:"last_used_at"` + ID string `json:"id"` +} + +func (q *Queries) TouchSession(ctx context.Context, arg TouchSessionParams) error { + _, err := q.db.ExecContext(ctx, touchSession, arg.LastUsedAt, arg.ID) + return err +} diff --git a/server/internal/store/streams.sql.go b/server/internal/store/streams.sql.go new file mode 100644 index 0000000..9767629 --- /dev/null +++ b/server/internal/store/streams.sql.go @@ -0,0 +1,225 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: streams.sql + +package store + +import ( + "context" + "database/sql" +) + +const appendStreamChunk = `-- name: AppendStreamChunk :exec +INSERT INTO stream_chunks (stream_id, seq, event, data, created_at) +VALUES (?, ?, ?, ?, ?) +` + +type AppendStreamChunkParams struct { + StreamID string `json:"stream_id"` + Seq int64 `json:"seq"` + Event string `json:"event"` + Data string `json:"data"` + CreatedAt int64 `json:"created_at"` +} + +func (q *Queries) AppendStreamChunk(ctx context.Context, arg AppendStreamChunkParams) error { + _, err := q.db.ExecContext(ctx, appendStreamChunk, + arg.StreamID, + arg.Seq, + arg.Event, + arg.Data, + arg.CreatedAt, + ) + return err +} + +const countActiveStreamsForUser = `-- name: CountActiveStreamsForUser :one +SELECT COUNT(*) FROM streams +WHERE user_id = ? AND status IN ('pending', 'running') +` + +func (q *Queries) CountActiveStreamsForUser(ctx context.Context, userID string) (int64, error) { + row := q.db.QueryRowContext(ctx, countActiveStreamsForUser, userID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createStream = `-- name: CreateStream :one +INSERT INTO streams ( + id, conversation_id, user_id, assistant_message_id, idempotency_key, + model, status, started_at +) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?) +RETURNING id, conversation_id, user_id, assistant_message_id, idempotency_key, model, status, error_code, error_message, started_at, finished_at +` + +type CreateStreamParams struct { + ID string `json:"id"` + ConversationID string `json:"conversation_id"` + UserID string `json:"user_id"` + AssistantMessageID sql.NullString `json:"assistant_message_id"` + IdempotencyKey string `json:"idempotency_key"` + Model string `json:"model"` + StartedAt int64 `json:"started_at"` +} + +func (q *Queries) CreateStream(ctx context.Context, arg CreateStreamParams) (Stream, error) { + row := q.db.QueryRowContext(ctx, createStream, + arg.ID, + arg.ConversationID, + arg.UserID, + arg.AssistantMessageID, + arg.IdempotencyKey, + arg.Model, + arg.StartedAt, + ) + var i Stream + err := row.Scan( + &i.ID, + &i.ConversationID, + &i.UserID, + &i.AssistantMessageID, + &i.IdempotencyKey, + &i.Model, + &i.Status, + &i.ErrorCode, + &i.ErrorMessage, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} + +const getStream = `-- name: GetStream :one +SELECT id, conversation_id, user_id, assistant_message_id, idempotency_key, model, status, error_code, error_message, started_at, finished_at FROM streams WHERE id = ? +` + +func (q *Queries) GetStream(ctx context.Context, id string) (Stream, error) { + row := q.db.QueryRowContext(ctx, getStream, id) + var i Stream + err := row.Scan( + &i.ID, + &i.ConversationID, + &i.UserID, + &i.AssistantMessageID, + &i.IdempotencyKey, + &i.Model, + &i.Status, + &i.ErrorCode, + &i.ErrorMessage, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} + +const getStreamByIdempotencyKey = `-- name: GetStreamByIdempotencyKey :one +SELECT id, conversation_id, user_id, assistant_message_id, idempotency_key, model, status, error_code, error_message, started_at, finished_at FROM streams WHERE user_id = ? AND idempotency_key = ? +` + +type GetStreamByIdempotencyKeyParams struct { + UserID string `json:"user_id"` + IdempotencyKey string `json:"idempotency_key"` +} + +func (q *Queries) GetStreamByIdempotencyKey(ctx context.Context, arg GetStreamByIdempotencyKeyParams) (Stream, error) { + row := q.db.QueryRowContext(ctx, getStreamByIdempotencyKey, arg.UserID, arg.IdempotencyKey) + var i Stream + err := row.Scan( + &i.ID, + &i.ConversationID, + &i.UserID, + &i.AssistantMessageID, + &i.IdempotencyKey, + &i.Model, + &i.Status, + &i.ErrorCode, + &i.ErrorMessage, + &i.StartedAt, + &i.FinishedAt, + ) + return i, err +} + +const listStreamChunksAfter = `-- name: ListStreamChunksAfter :many +SELECT seq, event, data, created_at +FROM stream_chunks +WHERE stream_id = ? AND seq > ? +ORDER BY seq ASC +` + +type ListStreamChunksAfterParams struct { + StreamID string `json:"stream_id"` + Seq int64 `json:"seq"` +} + +type ListStreamChunksAfterRow struct { + Seq int64 `json:"seq"` + Event string `json:"event"` + Data string `json:"data"` + CreatedAt int64 `json:"created_at"` +} + +func (q *Queries) ListStreamChunksAfter(ctx context.Context, arg ListStreamChunksAfterParams) ([]ListStreamChunksAfterRow, error) { + rows, err := q.db.QueryContext(ctx, listStreamChunksAfter, arg.StreamID, arg.Seq) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListStreamChunksAfterRow{} + for rows.Next() { + var i ListStreamChunksAfterRow + if err := rows.Scan( + &i.Seq, + &i.Event, + &i.Data, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const maxStreamChunkSeq = `-- name: MaxStreamChunkSeq :one +SELECT COALESCE(MAX(seq), 0) FROM stream_chunks WHERE stream_id = ? +` + +func (q *Queries) MaxStreamChunkSeq(ctx context.Context, streamID string) (interface{}, error) { + row := q.db.QueryRowContext(ctx, maxStreamChunkSeq, streamID) + var coalesce interface{} + err := row.Scan(&coalesce) + return coalesce, err +} + +const setStreamStatus = `-- name: SetStreamStatus :exec +UPDATE streams SET status = ?, finished_at = ?, error_code = ?, error_message = ? +WHERE id = ? +` + +type SetStreamStatusParams struct { + Status string `json:"status"` + FinishedAt sql.NullInt64 `json:"finished_at"` + ErrorCode sql.NullString `json:"error_code"` + ErrorMessage sql.NullString `json:"error_message"` + ID string `json:"id"` +} + +func (q *Queries) SetStreamStatus(ctx context.Context, arg SetStreamStatusParams) error { + _, err := q.db.ExecContext(ctx, setStreamStatus, + arg.Status, + arg.FinishedAt, + arg.ErrorCode, + arg.ErrorMessage, + arg.ID, + ) + return err +} diff --git a/server/internal/store/users.sql.go b/server/internal/store/users.sql.go new file mode 100644 index 0000000..b9d204a --- /dev/null +++ b/server/internal/store/users.sql.go @@ -0,0 +1,90 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: users.sql + +package store + +import ( + "context" + "database/sql" +) + +const createUser = `-- name: CreateUser :one +INSERT INTO users (id, email, display_name, created_at) +VALUES (?, ?, ?, ?) +RETURNING id, email, display_name, created_at, last_seen_at +` + +type CreateUserParams struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + CreatedAt int64 `json:"created_at"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRowContext(ctx, createUser, + arg.ID, + arg.Email, + arg.DisplayName, + arg.CreatedAt, + ) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.DisplayName, + &i.CreatedAt, + &i.LastSeenAt, + ) + return i, err +} + +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, email, display_name, created_at, last_seen_at FROM users WHERE email = ? +` + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByEmail, email) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.DisplayName, + &i.CreatedAt, + &i.LastSeenAt, + ) + return i, err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, email, display_name, created_at, last_seen_at FROM users WHERE id = ? +` + +func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByID, id) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.DisplayName, + &i.CreatedAt, + &i.LastSeenAt, + ) + return i, err +} + +const touchUser = `-- name: TouchUser :exec +UPDATE users SET last_seen_at = ? WHERE id = ? +` + +type TouchUserParams struct { + LastSeenAt sql.NullInt64 `json:"last_seen_at"` + ID string `json:"id"` +} + +func (q *Queries) TouchUser(ctx context.Context, arg TouchUserParams) error { + _, err := q.db.ExecContext(ctx, touchUser, arg.LastSeenAt, arg.ID) + return err +} diff --git a/server/internal/stream/tee.go b/server/internal/stream/tee.go new file mode 100644 index 0000000..65e044c --- /dev/null +++ b/server/internal/stream/tee.go @@ -0,0 +1,200 @@ +// Package stream owns the SSE tee. +// +// One producer goroutine reads chunks from pioneer.ai, persists each chunk +// to stream_chunks (durability before latency), then publishes to an +// in-memory bus that fans out to any number of subscribers. Subscribers +// can join late and resume from a known seq via DB replay before attaching +// to the live bus. +// +// Critical correctness properties (do not break without updating +// design/streaming.md): +// +// 1. The producer context is independent of any HTTP request context. +// Client disconnects must NOT cancel in-flight generations. +// 2. A chunk is persisted BEFORE it is published. A subscriber that sees +// seq=N is guaranteed to find seq=N in the DB on resume. +// 3. Subscribers that fall behind get dropped (their channel buffer fills +// and we close them) rather than blocking the producer. +// 4. The done signal is delivered exactly once per subscriber. +package stream + +import ( + "context" + "encoding/json" + "sync" + "time" + + "github.com/taciturnaxolotl/potluck/internal/store" +) + +// Event is the wire shape we publish to subscribers and persist as JSON. +type Event struct { + Seq int64 `json:"seq"` + Type string `json:"type"` // "delta" | "usage" | "error" | "done" + Content string `json:"content,omitempty"` + Usage *UsageEvent `json:"usage,omitempty"` + Error *ErrorEvent `json:"error,omitempty"` + Raw json.RawMessage `json:"-"` +} + +type UsageEvent struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +type ErrorEvent struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// Bus owns one in-memory fan-out for one stream id. The Hub holds many. +type Bus struct { + mu sync.Mutex + subs map[chan Event]struct{} + finalSeq int64 + done bool + doneEvent Event +} + +func newBus() *Bus { + return &Bus{subs: map[chan Event]struct{}{}} +} + +// Publish sends ev to every live subscriber. Slow subscribers (full buffer) +// are dropped. +func (b *Bus) Publish(ev Event) { + b.mu.Lock() + defer b.mu.Unlock() + for ch := range b.subs { + select { + case ch <- ev: + default: + // subscriber too slow — drop it; client should resume via /events + delete(b.subs, ch) + close(ch) + } + } + if ev.Type == "done" || ev.Type == "error" { + b.done = true + b.doneEvent = ev + b.finalSeq = ev.Seq + for ch := range b.subs { + delete(b.subs, ch) + close(ch) + } + } +} + +// Subscribe returns a channel that will receive events as they're published. +// If the stream has already terminated, returns a closed channel and the +// terminal event so the caller can replay from DB and finish. +func (b *Bus) Subscribe(buf int) (<-chan Event, bool, Event) { + b.mu.Lock() + defer b.mu.Unlock() + if b.done { + ch := make(chan Event) + close(ch) + return ch, true, b.doneEvent + } + ch := make(chan Event, buf) + b.subs[ch] = struct{}{} + return ch, false, Event{} +} + +// Hub keeps a Bus per active stream id. Buses are GC'd when their last +// subscriber leaves AFTER the stream is done. +type Hub struct { + mu sync.Mutex + buses map[string]*Bus + q *store.Queries +} + +func NewHub(q *store.Queries) *Hub { + return &Hub{buses: map[string]*Bus{}, q: q} +} + +func (h *Hub) bus(streamID string) *Bus { + h.mu.Lock() + defer h.mu.Unlock() + b, ok := h.buses[streamID] + if !ok { + b = newBus() + h.buses[streamID] = b + } + return b +} + +// Subscriber returns the bus for a stream id (creating one if needed). +// Used by /api/streams/:id/events handlers. +func (h *Hub) Subscriber(streamID string) *Bus { return h.bus(streamID) } + +// Producer is the goroutine that reads from upstream and writes to a Bus. +// Use Run for a single stream. +type Producer struct { + StreamID string + Hub *Hub + Q *store.Queries +} + +// Run consumes ev from src, persists each event, and publishes it. The +// caller is responsible for setting the stream row's status on completion. +// +// ctx must NOT be a request context — the lifetime here is "as long as +// pioneer is willing to send", not "as long as the user is connected". +func (p *Producer) Run(ctx context.Context, src <-chan Event) { + bus := p.Hub.bus(p.StreamID) + for { + select { + case <-ctx.Done(): + bus.Publish(Event{Seq: nextSeq(bus), Type: "error", Error: &ErrorEvent{Code: "canceled", Message: ctx.Err().Error()}}) + return + case ev, ok := <-src: + if !ok { + return + } + ev.Seq = nextSeq(bus) + data, _ := json.Marshal(ev) + // Persist BEFORE publishing — durability over latency. + _ = p.Q.AppendStreamChunk(ctx, store.AppendStreamChunkParams{ + StreamID: p.StreamID, + Seq: ev.Seq, + Event: ev.Type, + Data: string(data), + CreatedAt: time.Now().Unix(), + }) + bus.Publish(ev) + if ev.Type == "done" || ev.Type == "error" { + return + } + } + } +} + +func nextSeq(b *Bus) int64 { + b.mu.Lock() + defer b.mu.Unlock() + b.finalSeq++ + return b.finalSeq +} + +// Replay loads chunks from the DB with seq > afterSeq and yields them in +// order. Used by resume; the caller typically follows up by attaching to +// the live bus. +func Replay(ctx context.Context, q *store.Queries, streamID string, afterSeq int64) ([]Event, error) { + rows, err := q.ListStreamChunksAfter(ctx, store.ListStreamChunksAfterParams{ + StreamID: streamID, + Seq: afterSeq, + }) + if err != nil { + return nil, err + } + out := make([]Event, 0, len(rows)) + for _, r := range rows { + var ev Event + _ = json.Unmarshal([]byte(r.Data), &ev) + ev.Seq = r.Seq + ev.Type = r.Event + out = append(out, ev) + } + return out, nil +} diff --git a/server/internal/stream/tee_test.go b/server/internal/stream/tee_test.go new file mode 100644 index 0000000..53aa503 --- /dev/null +++ b/server/internal/stream/tee_test.go @@ -0,0 +1,113 @@ +package stream_test + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/google/uuid" + _ "modernc.org/sqlite" + + "github.com/taciturnaxolotl/potluck/internal/migrations" + "github.com/taciturnaxolotl/potluck/internal/store" + "github.com/taciturnaxolotl/potluck/internal/stream" +) + +func newTestStore(t *testing.T) (*store.Queries, string) { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + if err := migrations.Run(db); err != nil { + t.Fatal(err) + } + q := store.New(db) + ctx := context.Background() + now := time.Now().Unix() + + u, err := q.CreateUser(ctx, store.CreateUserParams{ID: uuid.NewString(), Email: "t@t", DisplayName: "t", CreatedAt: now}) + if err != nil { + t.Fatal(err) + } + conv, err := q.CreateConversation(ctx, store.CreateConversationParams{ID: uuid.NewString(), UserID: u.ID, Title: "x", CreatedAt: now, UpdatedAt: now}) + if err != nil { + t.Fatal(err) + } + str, err := q.CreateStream(ctx, store.CreateStreamParams{ + ID: uuid.NewString(), + ConversationID: conv.ID, + UserID: u.ID, + AssistantMessageID: sql.NullString{}, + IdempotencyKey: uuid.NewString(), + Model: "fake", + StartedAt: now, + }) + if err != nil { + t.Fatal(err) + } + return q, str.ID +} + +// Required by AGENTS.md "Testing": resume after mid-stream disconnect. +func TestProducerPersistsBeforePublish(t *testing.T) { + q, streamID := newTestStore(t) + hub := stream.NewHub(q) + prod := &stream.Producer{StreamID: streamID, Hub: hub, Q: q} + + src := make(chan stream.Event, 4) + src <- stream.Event{Type: "delta", Content: "hello "} + src <- stream.Event{Type: "delta", Content: "world"} + src <- stream.Event{Type: "done"} + close(src) + + prod.Run(context.Background(), src) + + // All chunks should be in the DB after the producer returns. + got, err := stream.Replay(context.Background(), q, streamID, 0) + if err != nil { + t.Fatal(err) + } + if len(got) != 3 { + t.Fatalf("want 3 events, got %d", len(got)) + } + if got[0].Content != "hello " || got[1].Content != "world" { + t.Errorf("unexpected content: %+v", got) + } + if got[2].Type != "done" { + t.Errorf("last event should be done, got %q", got[2].Type) + } +} + +// Required by AGENTS.md "Testing": slow subscriber doesn't stall producer. +func TestSlowSubscriberDoesNotStall(t *testing.T) { + q, streamID := newTestStore(t) + hub := stream.NewHub(q) + bus := hub.Subscriber(streamID) + + // Subscribe with a tiny buffer; never read from it. + sub, _, _ := bus.Subscribe(1) + _ = sub + + prod := &stream.Producer{StreamID: streamID, Hub: hub, Q: q} + src := make(chan stream.Event, 50) + for i := 0; i < 20; i++ { + src <- stream.Event{Type: "delta", Content: "x"} + } + src <- stream.Event{Type: "done"} + close(src) + + done := make(chan struct{}) + go func() { + prod.Run(context.Background(), src) + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("producer stalled on slow subscriber") + } +} diff --git a/server/sqlc.yaml b/server/sqlc.yaml new file mode 100644 index 0000000..ac9f1cf --- /dev/null +++ b/server/sqlc.yaml @@ -0,0 +1,15 @@ +version: "2" +sql: + - engine: "sqlite" + queries: "db/queries" + schema: "db/migrations" + gen: + go: + package: "store" + sql_package: "database/sql" + out: "internal/store" + emit_json_tags: true + emit_prepared_queries: false + emit_interface: true + emit_exact_table_names: false + emit_empty_slices: true diff --git a/web/bun.lock b/web/bun.lock new file mode 100644 index 0000000..225feff --- /dev/null +++ b/web/bun.lock @@ -0,0 +1,574 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "potluck-web", + "dependencies": { + "@fontsource-variable/fraunces": "^5.0.0", + "@fontsource-variable/inter": "^5.0.0", + "@fontsource/ibm-plex-mono": "^5.0.0", + "dexie": "^4.0.8", + "uuid": "^11.0.0", + }, + "devDependencies": { + "@sveltejs/adapter-cloudflare": "^4.7.0", + "@sveltejs/kit": "^2.8.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@types/bun": "^1.1.0", + "prettier": "^3.3.0", + "prettier-plugin-svelte": "^3.2.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.5.0", + "vite": "^5.4.0", + "vitest": "^2.0.0", + }, + }, + }, + "packages": { + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.3.4", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.0.2", "", { "peerDependencies": { "unenv": "2.0.0-rc.14", "workerd": "^1.20250124.0" }, "optionalPeers": ["workerd"] }, "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250718.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250718.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20250718.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20250718.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250718.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260521.1", "", {}, "sha512-cerUUdckXWiNm0x0XEIomBkr58xja5hn/1F9wzzDfJTE96L98L/Ra0ToTxKfzsXLklPflOagce8Vhg0vx8Ytgw=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@esbuild-plugins/node-globals-polyfill": ["@esbuild-plugins/node-globals-polyfill@0.2.3", "", { "peerDependencies": { "esbuild": "*" } }, "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw=="], + + "@esbuild-plugins/node-modules-polyfill": ["@esbuild-plugins/node-modules-polyfill@0.2.2", "", { "dependencies": { "escape-string-regexp": "^4.0.0", "rollup-plugin-node-polyfills": "^0.2.1" }, "peerDependencies": { "esbuild": "*" } }, "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.24.2", "", { "os": "android", "cpu": "x64" }, "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.2", "", { "os": "none", "cpu": "x64" }, "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="], + + "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + + "@fontsource-variable/fraunces": ["@fontsource-variable/fraunces@5.2.9", "", {}, "sha512-Y6IjunlN9Ni723np+GIgAaKzCDBrPRrqNi01TZxHs5wtHYROWFM9W6yiT+/gGwSjWIRD18oX17kD/BRWekc/Lw=="], + + "@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="], + + "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.7", "", {}, "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="], + + "@sveltejs/adapter-cloudflare": ["@sveltejs/adapter-cloudflare@4.9.0", "", { "dependencies": { "@cloudflare/workers-types": "^4.20231121.0", "esbuild": "^0.24.0", "worktop": "0.8.0-next.18" }, "peerDependencies": { "@sveltejs/kit": "^2.0.0", "wrangler": "^3.28.4" } }, "sha512-o7o8wXy5zDsEuE9oPWSHO5tAuPEulZZg2QavFdc00fcIHh1dxgCyIRZa5LPjAE8EcdJOh+8SFkhFgVRdCfOBvQ=="], + + "@sveltejs/kit": ["@sveltejs/kit@2.60.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@4.0.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0", "debug": "^4.3.7", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.12", "vitefu": "^1.0.3" }, "peerDependencies": { "svelte": "^5.0.0-next.96 || ^5.0.0", "vite": "^5.0.0" } }, "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA=="], + + "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@3.0.1", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0", "svelte": "^5.0.0-next.96 || ^5.0.0", "vite": "^5.0.0" } }, "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="], + + "@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="], + + "@vitest/runner": ["@vitest/runner@2.1.9", "", { "dependencies": { "@vitest/utils": "2.1.9", "pathe": "^1.1.2" } }, "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g=="], + + "@vitest/snapshot": ["@vitest/snapshot@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "^0.30.12", "pathe": "^1.1.2" } }, "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ=="], + + "@vitest/spy": ["@vitest/spy@2.1.9", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ=="], + + "@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="], + + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + + "as-table": ["as-table@1.0.55", "", { "dependencies": { "printable-characters": "^1.0.42" } }, "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + + "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@2.0.2", "", {}, "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], + + "dexie": ["dexie@4.4.2", "", {}, "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "esrap": ["esrap@2.2.9", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-source": ["get-source@2.0.12", "", { "dependencies": { "data-uri-to-buffer": "^2.0.0", "source-map": "^0.6.1" } }, "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w=="], + + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + + "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], + + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + + "miniflare": ["miniflare@3.20250718.3", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "stoppable": "1.1.0", "undici": "^5.28.5", "workerd": "1.20250718.0", "ws": "8.18.0", "youch": "3.3.4", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ=="], + + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + + "prettier-plugin-svelte": ["prettier-plugin-svelte@3.5.2", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-ItFouLvzSFE3ulNl4DKoWM3BGcbDCNVpIyy/Y3F2gC3aNiGLxtFUdffVqO5Z5hhYG+DFT5KULWaxmeFFpdbvaQ=="], + + "printable-characters": ["printable-characters@1.0.42", "", {}, "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "regexparam": ["regexparam@3.0.0", "", {}, "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q=="], + + "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], + + "rollup-plugin-inject": ["rollup-plugin-inject@3.0.2", "", { "dependencies": { "estree-walker": "^0.6.1", "magic-string": "^0.25.3", "rollup-pluginutils": "^2.8.1" } }, "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w=="], + + "rollup-plugin-node-polyfills": ["rollup-plugin-node-polyfills@0.2.1", "", { "dependencies": { "rollup-plugin-inject": "^3.0.0" } }, "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA=="], + + "rollup-pluginutils": ["rollup-pluginutils@2.8.2", "", { "dependencies": { "estree-walker": "^0.6.1" } }, "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ=="], + + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + + "semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + + "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], + + "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "sourcemap-codec": ["sourcemap-codec@1.4.8", "", {}, "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "stacktracey": ["stacktracey@2.2.0", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="], + + "svelte": ["svelte@5.55.9", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.9", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg=="], + + "svelte-check": ["svelte-check@4.4.8", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="], + + "tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], + + "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "unenv": ["unenv@2.0.0-rc.14", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.1", "ohash": "^2.0.10", "pathe": "^2.0.3", "ufo": "^1.5.4" } }, "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q=="], + + "uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], + + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + + "vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="], + + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "workerd": ["workerd@1.20250718.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20250718.0", "@cloudflare/workerd-darwin-arm64": "1.20250718.0", "@cloudflare/workerd-linux-64": "1.20250718.0", "@cloudflare/workerd-linux-arm64": "1.20250718.0", "@cloudflare/workerd-windows-64": "1.20250718.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg=="], + + "worktop": ["worktop@0.8.0-next.18", "", { "dependencies": { "mrmime": "^2.0.0", "regexparam": "^3.0.0" } }, "sha512-+TvsA6VAVoMC3XDKR5MoC/qlLqDixEfOBysDEKnPIPou/NvoPWCAuXHXMsswwlvmEuvX56lQjvELLyLuzTKvRw=="], + + "wrangler": ["wrangler@3.114.17", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.3.4", "@cloudflare/unenv-preset": "2.0.2", "@esbuild-plugins/node-globals-polyfill": "0.2.3", "@esbuild-plugins/node-modules-polyfill": "0.2.2", "blake3-wasm": "2.1.5", "esbuild": "0.17.19", "miniflare": "3.20250718.3", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.14", "workerd": "1.20250718.0" }, "optionalDependencies": { "fsevents": "~2.3.2", "sharp": "^0.33.5" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250408.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA=="], + + "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="], + + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + + "zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], + + "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + + "miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + + "rollup/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "rollup-plugin-inject/estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="], + + "rollup-plugin-inject/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="], + + "rollup-pluginutils/estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="], + + "unenv/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "wrangler/esbuild": ["esbuild@0.17.19", "", { "optionalDependencies": { "@esbuild/android-arm": "0.17.19", "@esbuild/android-arm64": "0.17.19", "@esbuild/android-x64": "0.17.19", "@esbuild/darwin-arm64": "0.17.19", "@esbuild/darwin-x64": "0.17.19", "@esbuild/freebsd-arm64": "0.17.19", "@esbuild/freebsd-x64": "0.17.19", "@esbuild/linux-arm": "0.17.19", "@esbuild/linux-arm64": "0.17.19", "@esbuild/linux-ia32": "0.17.19", "@esbuild/linux-loong64": "0.17.19", "@esbuild/linux-mips64el": "0.17.19", "@esbuild/linux-ppc64": "0.17.19", "@esbuild/linux-riscv64": "0.17.19", "@esbuild/linux-s390x": "0.17.19", "@esbuild/linux-x64": "0.17.19", "@esbuild/netbsd-x64": "0.17.19", "@esbuild/openbsd-x64": "0.17.19", "@esbuild/sunos-x64": "0.17.19", "@esbuild/win32-arm64": "0.17.19", "@esbuild/win32-ia32": "0.17.19", "@esbuild/win32-x64": "0.17.19" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw=="], + + "youch/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.17.19", "", { "os": "android", "cpu": "arm" }, "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A=="], + + "wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.17.19", "", { "os": "android", "cpu": "arm64" }, "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA=="], + + "wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.17.19", "", { "os": "android", "cpu": "x64" }, "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww=="], + + "wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.17.19", "", { "os": "darwin", "cpu": "arm64" }, "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg=="], + + "wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.17.19", "", { "os": "darwin", "cpu": "x64" }, "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw=="], + + "wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.17.19", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ=="], + + "wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.17.19", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ=="], + + "wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.17.19", "", { "os": "linux", "cpu": "arm" }, "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA=="], + + "wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.17.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg=="], + + "wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.17.19", "", { "os": "linux", "cpu": "ia32" }, "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ=="], + + "wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ=="], + + "wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A=="], + + "wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.17.19", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg=="], + + "wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA=="], + + "wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.17.19", "", { "os": "linux", "cpu": "s390x" }, "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q=="], + + "wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.17.19", "", { "os": "linux", "cpu": "x64" }, "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw=="], + + "wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.17.19", "", { "os": "none", "cpu": "x64" }, "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q=="], + + "wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.17.19", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g=="], + + "wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.17.19", "", { "os": "sunos", "cpu": "x64" }, "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg=="], + + "wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.17.19", "", { "os": "win32", "cpu": "arm64" }, "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag=="], + + "wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.17.19", "", { "os": "win32", "cpu": "ia32" }, "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw=="], + + "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.17.19", "", { "os": "win32", "cpu": "x64" }, "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA=="], + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..3c6b289 --- /dev/null +++ b/web/package.json @@ -0,0 +1,35 @@ +{ + "name": "potluck-web", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run", + "fmt": "prettier --write .", + "lint": "prettier --check . && eslint ." + }, + "devDependencies": { + "@sveltejs/adapter-cloudflare": "^4.7.0", + "@sveltejs/kit": "^2.8.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.5.0", + "vite": "^5.4.0", + "vitest": "^2.0.0", + "prettier": "^3.3.0", + "prettier-plugin-svelte": "^3.2.0", + "@types/bun": "^1.1.0" + }, + "dependencies": { + "dexie": "^4.0.8", + "@fontsource-variable/fraunces": "^5.0.0", + "@fontsource-variable/inter": "^5.0.0", + "@fontsource/ibm-plex-mono": "^5.0.0", + "uuid": "^11.0.0" + } +} diff --git a/web/src/app.d.ts b/web/src/app.d.ts new file mode 100644 index 0000000..e5fdab4 --- /dev/null +++ b/web/src/app.d.ts @@ -0,0 +1,11 @@ +// See https://svelte.dev/docs/kit/types#app +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface Platform {} + } +} + +export {}; diff --git a/web/src/app.html b/web/src/app.html new file mode 100644 index 0000000..67959c5 --- /dev/null +++ b/web/src/app.html @@ -0,0 +1,26 @@ + + + + + + + + + potluck + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..a8298fa --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,88 @@ +/** + * Tiny `/api` client. Stays mechanical: all data flow is client-first via + * Dexie + this client. Don't add caching here. + */ + +export type APIError = { + code: string; + message: string; +}; + +export class HTTPError extends Error { + status: number; + code: string; + constructor(status: number, code: string, message: string) { + super(message); + this.status = status; + this.code = code; + } +} + +async function request(method: string, path: string, body?: unknown): Promise { + const res = await fetch(path, { + method, + headers: body ? { 'content-type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + credentials: 'include' + }); + if (!res.ok) { + let code = 'http_error', + message = res.statusText; + try { + const j = (await res.json()) as { error?: APIError }; + if (j.error) { + code = j.error.code; + message = j.error.message; + } + } catch { + /* ignore */ + } + throw new HTTPError(res.status, code, message); + } + // Empty body? Caller should pick a void return. + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} + +export const api = { + get: (path: string) => request('GET', path), + post: (path: string, body?: unknown) => request('POST', path, body), + del: (path: string) => request('DELETE', path) +}; + +// Typed helpers ------------------------------------------------------------ + +export type User = { + id: string; + email: string; + display_name: string; + created_at: number; + last_seen_at: number | null; +}; + +export type Conversation = { + id: string; + user_id: string; + title: string; + created_at: number; + updated_at: number; + archived_at: number | null; +}; + +export type Message = { + id: string; + conversation_id: string; + client_id: string | null; + role: 'user' | 'assistant' | 'system' | 'tool'; + content: string; + model: string | null; + created_at: number; +}; + +export const me = () => api.get('/api/me'); +export const balance = () => + api.get<{ balance_micros: number; balance_usd: string }>('/api/balance'); +export const listConversations = () => api.get('/api/conversations'); +export const createConversation = (title: string) => + api.post('/api/conversations', { title }); +export const listMessages = (id: string) => api.get(`/api/conversations/${id}/messages`); diff --git a/web/src/lib/db.ts b/web/src/lib/db.ts new file mode 100644 index 0000000..eaa4eaf --- /dev/null +++ b/web/src/lib/db.ts @@ -0,0 +1,61 @@ +/** + * Local-first conversation cache. + * + * Conversations and messages are mirrored into IndexedDB via Dexie. The + * server is the source of truth — Dexie holds optimistic UI state and a + * recent slice for offline / fast-paint. On reconnect we re-fetch and + * upsert by id. + */ + +import Dexie, { type Table } from 'dexie'; + +export interface DBConversation { + id: string; + title: string; + updated_at: number; + archived_at: number | null; +} + +export interface DBMessage { + id: string; // server id, or client_id while pending + conversation_id: string; + client_id: string | null; + role: 'user' | 'assistant' | 'system' | 'tool'; + content: string; + model: string | null; + created_at: number; + pending?: boolean; // optimistic; cleared on server upsert +} + +class PotluckDB extends Dexie { + conversations!: Table; + messages!: Table; + + constructor() { + super('potluck'); + this.version(1).stores({ + conversations: 'id, updated_at, archived_at', + messages: 'id, conversation_id, [conversation_id+created_at], created_at, client_id' + }); + } +} + +export const db = new PotluckDB(); + +// Helpers — keep these tiny. Pages do the wiring in $effect blocks. +export async function upsertConversation(c: DBConversation) { + await db.conversations.put(c); +} + +export async function upsertMessages(ms: DBMessage[]) { + await db.messages.bulkPut(ms); +} + +export async function appendAssistantDelta(messageID: string, delta: string) { + await db.transaction('rw', db.messages, async () => { + const m = await db.messages.get(messageID); + if (!m) return; + m.content += delta; + await db.messages.put(m); + }); +} diff --git a/web/src/lib/sanity.test.ts b/web/src/lib/sanity.test.ts new file mode 100644 index 0000000..d066d44 --- /dev/null +++ b/web/src/lib/sanity.test.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from 'vitest'; + +describe('placeholder', () => { + it('runs', () => { + expect(1 + 1).toBe(2); + }); +}); diff --git a/web/src/lib/stream.ts b/web/src/lib/stream.ts new file mode 100644 index 0000000..4811a1d --- /dev/null +++ b/web/src/lib/stream.ts @@ -0,0 +1,89 @@ +/** + * Client-side SSE consumer with resume. + * + * Subscribes to GET /api/streams/:id/events. On disconnect, reconnects with + * `?after_seq=` so the server replays missed chunks from DB before + * attaching us back to the live bus. + * + * This is the supported path for streaming; do not adopt @ai-sdk/svelte's + * `Chat` (it owns its own state machine and fights the Dexie-first model). + */ + +export interface StreamEvent { + seq: number; + type: 'delta' | 'usage' | 'error' | 'done'; + content?: string; + usage?: { input_tokens: number; output_tokens: number }; + error?: { code: string; message: string }; +} + +export interface StreamHandlers { + onEvent(ev: StreamEvent): void; + onClose?(reason: 'done' | 'error' | 'aborted'): void; +} + +export function consume(streamID: string, handlers: StreamHandlers): () => void { + let aborted = false; + let lastSeq = 0; + let controller = new AbortController(); + + const loop = async () => { + while (!aborted) { + try { + const res = await fetch(`/api/streams/${streamID}/events?after_seq=${lastSeq}`, { + credentials: 'include', + signal: controller.signal + }); + if (!res.ok || !res.body) { + handlers.onClose?.('error'); + return; + } + + const reader = res.body.pipeThrough(new TextDecoderStream()).getReader(); + let buf = ''; + // SSE frames are separated by a blank line. + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + buf += value; + let idx: number; + while ((idx = buf.indexOf('\n\n')) >= 0) { + const frame = buf.slice(0, idx); + buf = buf.slice(idx + 2); + const dataLine = frame + .split('\n') + .find((l) => l.startsWith('data:')) + ?.slice(5) + .trim(); + if (!dataLine) continue; + try { + const ev = JSON.parse(dataLine) as StreamEvent; + if (ev.seq > lastSeq) lastSeq = ev.seq; + handlers.onEvent(ev); + if (ev.type === 'done' || ev.type === 'error') { + handlers.onClose?.(ev.type); + return; + } + } catch { + /* malformed frame — skip */ + } + } + } + // EOF without done: server hung up. Reconnect with after_seq. + } catch (_e) { + if (aborted) return; + // network error — back off briefly and retry + await new Promise((r) => setTimeout(r, 500)); + controller = new AbortController(); + } + } + }; + + loop(); + + return () => { + aborted = true; + controller.abort(); + handlers.onClose?.('aborted'); + }; +} diff --git a/web/src/lib/styles/tokens.css b/web/src/lib/styles/tokens.css new file mode 100644 index 0000000..21af718 --- /dev/null +++ b/web/src/lib/styles/tokens.css @@ -0,0 +1,80 @@ +/* + * potluck design tokens. + * + * Two layers: + * 1. Raw palette. Six brand colors. Don't reference these in components. + * 2. Semantic tokens. Theme-aware via `light-dark()`. Use these everywhere. + * + * Theming is driven by ; theme.ts mutates that + * tag's `content` attribute. No `data-theme`, no `prefers-color-scheme` + * media queries — the cascade flips automatically. + * + * See design/design-system.md for rationale. + */ + +:root { + color-scheme: light dark; + + /* ---- raw palette ----------------------------------------------------- */ + --soft-blush: #ffd9da; + --blush-rose: #ea638c; + --dark-raspberry: #89023e; + --jet-black: #30343f; + --carbon-black: #1b2021; + --paper: #fffaf9; + + /* ---- semantic tokens ------------------------------------------------- */ + --bg-page: light-dark(var(--paper), var(--carbon-black)); + --bg-surface: light-dark(#ffffff, var(--jet-black)); + --bg-code: light-dark(var(--carbon-black), #110608); + --bg-highlight: light-dark(var(--soft-blush), var(--dark-raspberry)); + + --text: light-dark(var(--jet-black), var(--soft-blush)); + --text-muted: light-dark(rgba(48, 52, 63, 0.6), rgba(255, 217, 218, 0.5)); + --text-on-accent: light-dark(var(--soft-blush), var(--soft-blush)); + --text-on-code: var(--soft-blush); + + --border: light-dark(rgba(48, 52, 63, 0.1), rgba(255, 217, 218, 0.08)); + --accent: light-dark(var(--dark-raspberry), var(--blush-rose)); + + /* ---- type ----------------------------------------------------------- */ + --font-serif: 'Fraunces Variable', Georgia, serif; + --font-sans: 'Inter Variable', system-ui, -apple-system, sans-serif; + --font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, monospace; + + /* Fraunces opsz/SOFT presets — apply via font-variation-settings. */ + --fraunces-display: 'opsz' 144, 'SOFT' 50; + --fraunces-text: 'opsz' 9, 'SOFT' 0; + + /* Numeric tabular figures everywhere by default; override with + `font-feature-settings: "tnum" 0` when you specifically need + proportional digits in prose. */ + font-feature-settings: + 'tnum' 1, + 'cv11' 1; +} + +html, +body { + margin: 0; + background: var(--bg-page); + color: var(--text); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--accent); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +code, +kbd, +pre, +.mono, +.num { + font-family: var(--font-mono); +} diff --git a/web/src/lib/theme.ts b/web/src/lib/theme.ts new file mode 100644 index 0000000..4dc48a3 --- /dev/null +++ b/web/src/lib/theme.ts @@ -0,0 +1,59 @@ +/** + * Theme switcher. + * + * Three modes — auto, light, dark — driven entirely by mutating the + * `` tag's `content`. The browser propagates that + * to every `light-dark()` call in tokens.css, so no CSS changes are needed + * to flip themes. + * + * Cycle order is auto → dark → light → auto. First tap commits to dark + * (most users want this); third tap returns control to the OS. Don't change + * this without updating AGENTS.md and design/design-system.md. + * + * The matching app.html boot script runs synchronously before any stylesheet + * to avoid a flash of the wrong theme on load. + */ + +export type Theme = 'auto' | 'light' | 'dark'; + +const STORAGE_KEY = 'potluck-theme'; + +function meta(): HTMLMetaElement | null { + if (typeof document === 'undefined') return null; + return document.querySelector('meta[name="color-scheme"]'); +} + +export function currentTheme(): Theme { + const m = meta(); + if (!m) return 'auto'; + const c = m.content.trim(); + if (c === 'light' || c === 'dark') return c; + return 'auto'; +} + +export function setTheme(mode: Theme): void { + const m = meta(); + if (!m) return; + if (mode === 'auto') { + m.content = 'light dark'; + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + /* ignore */ + } + return; + } + m.content = mode; + try { + localStorage.setItem(STORAGE_KEY, mode); + } catch { + /* ignore */ + } +} + +export function cycleTheme(): Theme { + const next: Record = { auto: 'dark', dark: 'light', light: 'auto' }; + const t = next[currentTheme()]; + setTheme(t); + return t; +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte new file mode 100644 index 0000000..719bef5 --- /dev/null +++ b/web/src/routes/+layout.svelte @@ -0,0 +1,122 @@ + + + + + + +
+ +
+ {@render children()} +
+
+ + diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte new file mode 100644 index 0000000..dab926a --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,72 @@ + + +

potluck

+

a token melting pot for hackclubbers.

+ +{#if err} +

not signed in. visit /api/dev/login?email=you@example.com

+{:else if user} +
+

your stake

+

${bal?.balance_usd ?? '—'}

+

signed in as {user.email}

+
+{:else} +

loading…

+{/if} + + diff --git a/web/src/routes/api/[...path]/+server.ts b/web/src/routes/api/[...path]/+server.ts new file mode 100644 index 0000000..5086433 --- /dev/null +++ b/web/src/routes/api/[...path]/+server.ts @@ -0,0 +1,40 @@ +/** + * /api/* proxy. + * + * The Cloudflare Worker (this server runtime) is intentionally dumb: take + * the inbound request, swap in BACKEND_URL, forward it. No auth, no + * rewriting, no caching. All real work happens in the Go backend. + * + * Streaming is preserved because we hand the original body and ReadableStream + * straight through. + */ + +import type { RequestHandler } from './$types'; + +const proxy: RequestHandler = async ({ request, url, platform }) => { + // SvelteKit's $env is overkill for one variable; read straight from + // platform.env in production, fall back for local dev. + const backend = + (platform?.env as { BACKEND_URL?: string } | undefined)?.BACKEND_URL ?? + 'http://localhost:8080'; + + const target = new URL(url.pathname + url.search, backend); + + const init: RequestInit = { + method: request.method, + headers: request.headers, + body: + request.method === 'GET' || request.method === 'HEAD' ? undefined : (request.body as never), + redirect: 'manual' + }; + // Streaming bodies need duplex on fetch(). + if (init.body) (init as { duplex?: string }).duplex = 'half'; + + return fetch(target, init); +}; + +export const GET = proxy; +export const POST = proxy; +export const PUT = proxy; +export const PATCH = proxy; +export const DELETE = proxy; diff --git a/web/src/routes/chat/+page.svelte b/web/src/routes/chat/+page.svelte new file mode 100644 index 0000000..18d9699 --- /dev/null +++ b/web/src/routes/chat/+page.svelte @@ -0,0 +1,94 @@ + + +
+

chat

+ +
+ +{#if loading} +

loading…

+{:else if err} +

{err}

+{:else if conversations.length === 0} +

no conversations yet. create one to get started.

+{:else} +
    + {#each conversations as c (c.id)} +
  • + {c.title || 'untitled'} + {new Date(c.updated_at * 1000).toLocaleString()} +
  • + {/each} +
+{/if} + + diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 0000000..79cdfaf --- /dev/null +++ b/web/svelte.config.js @@ -0,0 +1,18 @@ +import adapter from '@sveltejs/adapter-cloudflare'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter(), + alias: { + $lib: 'src/lib' + } + }, + compilerOptions: { + runes: true + } +}; + +export default config; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..4344710 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..2a0ab80 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,15 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: false + } + } + } +}); diff --git a/web/wrangler.toml b/web/wrangler.toml new file mode 100644 index 0000000..8f37cac --- /dev/null +++ b/web/wrangler.toml @@ -0,0 +1,10 @@ +name = "potluck" +main = ".svelte-kit/cloudflare/_worker.js" +compatibility_date = "2025-04-01" +compatibility_flags = ["nodejs_compat"] + +[site] +bucket = ".svelte-kit/cloudflare" + +[vars] +BACKEND_URL = "https://potluck-api.dunkirk.sh"