ugly-app 0.1.461 → 0.1.464

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,18 +1,22 @@
1
1
  # ugly-app
2
2
 
3
- A full-stack TypeScript framework for building production-ready web applications. Scaffold with `npx ugly-app init my-app` and get an opinionated Express + React + MongoDB stack with built-in auth, real-time WebSockets, AI generation, storage, and a CLI for every workflow.
3
+ A full-stack TypeScript framework for shipping production web apps with one CLI. Scaffold with `npx ugly-app init my-app` and get an opinionated Express + React + PostgreSQL stack with built-in auth, type-safe RPC over WebSocket and HTTP, real-time document tracking, AI generation, storage, and a CLI for every workflow.
4
+
5
+ ugly-app is designed to be deployed and operated through [ugly.bot](https://ugly.bot) — the platform handles auth, infra (PostgreSQL, Qdrant, NATS, S3-compatible object storage), AI provider keys, and deployment. Your app talks to all of this through the project's dev tunnel and the per-app `UGLY_BOT_TOKEN`.
4
6
 
5
7
  ## What's included
6
8
 
7
9
  - **Server**: Express + WebSocket with type-safe RPC and Zod validation
8
- - **Client**: React + Vite with typed routing, lazy pages, and popup management
9
- - **Database**: MongoDB with typed collections, dot-notation updates, indexes, migrations, and live document tracking
10
- - **Auth**: JWT + HttpOnly cookies, ugly.bot OAuth out of the box, extensible via `AuthProvider`
11
- - **AI**: Text generation (Together, Claude, OpenAI, Google, Groq, Fireworks, Kie) + image generation (Together, FAL, Google, Wavespeed, Kie) + embeddings + STT/TTS
12
- - **Storage**: Cloudflare R2 / AWS S3 with presigned uploads
13
- - **Web search**: Kagi and UglyBot providers with search, summarize, and enrich
14
- - **Billing**: Usage-based billing with per-user/global limits, credits, and threshold alerts
15
- - **CLI**: `ugly-app` commands for dev, build, deploy, migrations, logs, and auth utilities
10
+ - **Client**: React + Vite with typed routing, lazy pages, animated transitions, popup management
11
+ - **Database**: PostgreSQL (JSONB) via the data proxy, with full-text search (`search`) and vector search (Qdrant `vector`)
12
+ - **Auth**: HttpOnly cookies + JWT, ugly.bot OAuth out of the box, extensible via `AuthProvider`
13
+ - **AI**: Text, image, embeddings, web search all proxied through ugly.bot (no per-provider keys in your app)
14
+ - **Realtime**: NATS pub/sub and document change subscriptions (`trackDoc` / `trackDocs`)
15
+ - **Storage**: S3-compatible buckets with presigned uploads
16
+ - **Workers & cron**: `setWorkers()` registers named async tasks with optional Zod input schemas and cron schedules
17
+ - **Localization**: Strings tables with critical-string SSR injection
18
+ - **Experiments**: Deterministic A/B bucketing tied to event logging
19
+ - **CLI**: `ugly-app` commands for dev, build, deploy, migrations, logs, AI, and auth
16
20
 
17
21
  ## Quick start
18
22
 
@@ -22,112 +26,127 @@ cd my-app
22
26
  npm run dev
23
27
  ```
24
28
 
29
+ The scaffold gives you a working app at `http://localhost:4321` with todo CRUD, AI chat, file upload, auth demo, collab editing, and ~20 other test pages wired up.
30
+
25
31
  ---
26
32
 
27
33
  ## Server
28
34
 
29
35
  ### `createApp()`
30
36
 
31
- Entry point for the server. Creates an Express + WebSocket server with typed RPC, auth, and all framework services.
37
+ The single server entry point. Returns an `App` that owns Express, the WebSocket server, the typed DB, and the RPC dispatcher.
32
38
 
33
39
  ```typescript
34
40
  import {
35
41
  createApp,
36
- createUserHelper,
37
- getFeedbackHandlers,
38
42
  type AppConfigurator,
39
43
  type RequestHandlers,
40
44
  } from 'ugly-app';
41
45
  import { dbDefaults } from 'ugly-app/shared';
42
- import { requests } from '../shared/api';
46
+ import { requests, messages } from '../shared/api';
43
47
  import { collections } from '../shared/collections';
44
48
  import { pages } from '../shared/pages';
45
- import type { User } from '../shared/collections';
46
-
47
- const userHelper = createUserHelper<User>(collections.user);
48
- const maintainBotUserId = process.env.MAINTAIN_BOT_USER_ID ?? '';
49
49
 
50
50
  const app = createApp(
51
- { requests },
51
+ { requests, messages },
52
52
  {
53
- ...getFeedbackHandlers(maintainBotUserId),
54
- getMe: async (userId: string) => {
55
- const user = await userHelper.get(app.db, userId);
56
- return { userId, email: user?.email, phone: user?.phone };
53
+ createTodo: async (userId, { text }) => {
54
+ const _id = crypto.randomUUID();
55
+ await app.db.setDoc(collections.todo, { _id, userId, text, done: false, ...dbDefaults() });
56
+ return { id: _id };
57
57
  },
58
58
  } satisfies RequestHandlers<typeof requests>,
59
59
  collections,
60
60
  (configurator: AppConfigurator) => {
61
61
  configurator.setPages({ pages });
62
- configurator.setUserHelper(userHelper);
63
- configurator.setOnUserCreate(async (userId, info, db) => {
64
- await userHelper.set(db, { id: userId, ...dbDefaults(), ...info });
65
- });
66
62
  },
67
63
  );
68
64
 
69
- const port = parseInt(process.env['PORT'] ?? '3000');
70
- await app.start(port);
65
+ await app.start(parseInt(process.env['PORT'] ?? '4321'));
71
66
  ```
72
67
 
73
68
  **Signature:**
74
69
 
75
70
  ```typescript
76
71
  function createApp<R extends AppRegistryBase, Defs extends CollectionDefRegistry>(
77
- registry: R,
78
- requests: Partial<RequestHandlers<R['requests']>>,
79
- appDefs: Defs,
80
- configure?: (configurator: AppConfigurator) => void,
81
- ): App;
72
+ registry: R, // { requests, messages }
73
+ requests: Partial<RequestHandlers<R['requests']>>, // handler implementations
74
+ appDefs: Defs, // collections from defineCollections()
75
+ configure?: (c: AppConfigurator) => void,
76
+ deleteHandlers?: DeleteHandlers<Defs>, // per-collection onDelete hooks
77
+ ): App<CollectionMap<typeof BUILTIN_DEFS & Defs>>;
82
78
  ```
83
79
 
84
80
  The returned `App` object has:
85
- - `start(port?)` — starts the server (default port 3000)
86
- - `registerRoutes(fn)` — mount additional Express routes after creation
87
- - `httpServer` — the underlying Node.js HTTP server
88
- - `db` — the `TypedDB` instance for direct database access
89
- - `wss` — the main `WebSocketServer` instance (path configured via `setWsPath`, default `'/rpc'`)
81
+ - `start(port?)` — start the server (default port 3000; templates use 4321)
82
+ - `db` — the `TypedDB` instance, also available globally via imports
83
+ - `httpServer` — the underlying Node `http.Server`
84
+ - `wss` — the main `WebSocketServer` (path set by `setWsPath`, default `/rpc`)
90
85
  - `dispatch(name, input, userId)` — invoke an RPC handler programmatically
86
+ - `registerRoutes(fn)` — mount more Express routes after creation
87
+
88
+ Framework-managed background services start automatically: schema drift check, NATS connection + KV buckets (`TTS`, `RATELIMIT`), data-proxy connection, event counter flush, TTL cleanup for log tables, console / error capture, and ugly.bot log forwarding.
91
89
 
92
90
  ### `AppConfigurator`
93
91
 
94
- The optional fourth argument to `createApp` receives a configurator object:
92
+ Passed to the optional fourth argument of `createApp`. Every method is optional.
95
93
 
96
94
  | Method | Description |
97
95
  |--------|-------------|
98
- | `setPages(options)` | Serves pages with Vite (dev) or static files (prod). `options: { pages, renderPage?, clientDistPath? }` |
99
- | `setUserHelper(helper)` | User lookup for WebSocket auth handshake |
100
- | `setOnUserCreate(handler)` | Called on first login must create the user record. `(userId, { email?, phone? }, db) => Promise<void>` |
101
- | `setAuth(provider)` | Custom `AuthProvider` (default: ugly.bot OAuth). Provider must implement `verify(code)` and `authUrl(origin)` |
102
- | `setOnSocketMessage(handler)` | Handle raw WebSocket messages. Return `true` to consume, `false` to let the framework handle it |
103
- | `registerRoutes(fn)` | Mount custom Express routes `(router: express.Router) => void` |
104
- | `setWorkerQueue(queue)` | Register a background worker queue with `start()` and `stop()` |
105
- | `setWsPath(path)` | Override the WebSocket path (default: `'/rpc'`) |
106
- | `setOnWsAuth(handler)` | Called after a WebSocket session is authenticated. `(ws, userId, req) => void` |
107
- | `setOnAfterStart(handler)` | Called once after MongoDB, Redis, and NATS are ready. `(db) => Promise<void>` |
108
- | `setOnMinuteTick(handler)` | Fires every minute (only when `CLOCK_ENABLED=true`). `() => Promise<void>` |
109
- | `setOnHourlyTick(handler)` | Fires when the hour changes (only when `CLOCK_ENABLED=true`). `(now, currentHour) => Promise<void>` |
110
- | `setHealthHandler(handler)` | Override the default `GET /health` endpoint handler. `(req, res) => void` |
96
+ | `setPages({ pages, renderPage?, clientDistPath? })` | Mount the SPA. In dev, runs Vite in middleware mode; in prod, serves `dist/client`. Provide `renderPage` for SSR on pages with `ssr: true`. |
97
+ | `setUserHelper(helper)` | Customize how the framework reads / writes the `user` collection during WebSocket auth (default looks up by id in a generic `user` collection). |
98
+ | `setOnUserCreate(handler)` | Called on first login with `(userId, { email?, phone? }, db)` your chance to create the user record. |
99
+ | `setAuth(provider)` | Replace the default ugly.bot OAuth provider. Must implement `verify(code)` and `authUrl(origin)`. |
100
+ | `setOnSocketMessage(handler)` | Single raw-WebSocket message handler. Return `true` to consume, `false` to fall through. |
101
+ | `addSocketMessageHandler(handler)` | Append to the handler chain; first to return `true` wins. |
102
+ | `setWsPath(path)` | Override the WebSocket path (default `/rpc`). |
103
+ | `setOnWsAuth(handler)` | `(ws, userId, req) => void` — fires after a socket session authenticates. |
104
+ | `setOnAfterStart(handler)` | `(db) => Promise<void>` called once after data-proxy + NATS are ready. |
105
+ | `setOnMinuteTick(fn)` / `setOnHourlyTick(fn)` | Framework-managed periodic callbacks. Only fire when `CLOCK_ENABLED=true`. |
106
+ | `setHealthHandler(fn)` | Override the default `GET /health` response. |
107
+ | `setExperiments(experiments)` | Register `Experiment` definitions for `initSession` / `captureEvent` bucketing. |
108
+ | `setOnEmail(handler)` | Handle inbound emails routed to `{domain}@ugly.bot` (called via internal HTTP). |
109
+ | `setCronTasks(tasks, handlers)` | Legacy cron-only registry. Prefer `setWorkers()`. |
110
+ | `setWorkers(workers, handlers)` | Register named async tasks with optional Zod input schema and cron schedule. Powers `/_workers/manifest`, `POST /_workers/run`, and the cron orchestrator. |
111
+ | `setStrings(config)` | Localization config — framework injects language + critical strings into SSR HTML and exposes `resolveLanguage` / `getCriticalStrings`. |
112
+ | `registerRoutes(fn)` | Mount custom Express routes. |
113
+ | `setWorkerQueue(queue)` | Register a `WorkerQueue` with `start()` / `stop()` for app lifecycle management. |
111
114
 
112
115
  ### Handler signatures
113
116
 
114
- Handlers are plain async functions — no context object, just `userId` and `input`:
117
+ Handlers are plain async functions — no context object:
115
118
 
116
119
  ```typescript
117
120
  // req() — public, userId may be null
118
121
  getPublicData: async (userId: string | null, input) => { ... }
119
122
 
120
- // authReq() — authenticated, 401 auto-enforced, userId always a string
123
+ // authReq() — authenticated, framework returns 401 if no/invalid token
121
124
  getMe: async (userId: string, input) => { ... }
122
125
  ```
123
126
 
124
- Access `app.db`, storage, and AI clients directly via imports or the `app` object they are not injected into handlers.
127
+ Inside a handler, access state via captured imports `app.db`, `storage`, `pgQuery`, `uglyBotRequest`, etc. There is no injected context.
128
+
129
+ ### Built-in framework requests
130
+
131
+ `createApp` automatically registers several framework handlers, accessible from any client via the normal RPC pipeline:
132
+
133
+ | Name | Purpose |
134
+ |------|---------|
135
+ | `userGet` | Returns `{ userId, name, avatarUri }` for the given user (or caller). |
136
+ | `initSession` | Records a session start, returns experiment branch assignments. |
137
+ | `captureEvent` | Records a client event tied to the session and experiment branches. |
138
+ | `textGen` / `imageGen` | AI proxies — server-validated, billed through ugly.bot. |
139
+ | `kagiSearch` / `kagiSummarize` / `kagiEnrichWeb` / `kagiEnrichNews` | Web search via ugly.bot. |
140
+ | `uploadUrl` | Issues a presigned PUT for the `temp` bucket. |
141
+ | `submitFeedbackBot` | Forwards `db.captureFeedback` writes for the maintain-bot persona. |
142
+
143
+ App-provided handlers with the same name override the framework's defaults.
125
144
 
126
145
  ---
127
146
 
128
147
  ## Shared API definitions
129
148
 
130
- All type definitions live in `shared/` and are used by both server and client.
149
+ `shared/` is consumed by both server and client. Keep all Zod schemas, types, collections, and route declarations here.
131
150
 
132
151
  ### Requests (`shared/api.ts`)
133
152
 
@@ -135,19 +154,19 @@ All type definitions live in `shared/` and are used by both server and client.
135
154
  import { authReq, defineRequests, req, z } from 'ugly-app/shared';
136
155
 
137
156
  export const requests = defineRequests({
138
- // Public request — handler receives (userId: string | null, input)
157
+ // Public — handler signature: (userId: string | null, input) => Promise<output>
139
158
  getPublicData: req({
140
159
  input: z.object({ id: z.string() }),
141
160
  output: z.object({ data: z.string() }),
142
161
  }),
143
162
 
144
- // Authenticated request handler receives (userId: string, input)
163
+ // Authenticated — 401 enforced automatically, userId guaranteed string
145
164
  getMe: authReq({
146
165
  input: z.object({}),
147
166
  output: z.object({ userId: z.string(), email: z.string().optional() }),
148
167
  }),
149
168
 
150
- // With rate limiting
169
+ // With per-endpoint rate limiting (enforced before handler runs)
151
170
  submitFeedback: authReq({
152
171
  input: z.object({ type: z.enum(['bug', 'design', 'feature']), message: z.string() }),
153
172
  output: z.object({ id: z.string() }),
@@ -156,42 +175,41 @@ export const requests = defineRequests({
156
175
  });
157
176
  ```
158
177
 
159
- - `req({ input, output })` defines a public request. Handler signature: `(userId: string | null, input: I) => Promise<O>`.
160
- - `authReq({ input, output })` — defines an authenticated request. Handler signature: `(userId: string, input: I) => Promise<O>`. Returns 401 automatically if no token.
161
- - `defineRequests()` — identity wrapper that preserves types.
162
- - `z` is re-exported from Zod for convenience.
163
-
164
- Every endpoint is accessible via both WebSocket (`socket.request(name, input)`) and HTTP (`POST /api/:name { input }`).
178
+ Every request is reachable as **both** `socket.request(name, input)` (WebSocket) and `POST /api/:name { input }` (HTTP). `z` is re-exported from Zod for convenience.
165
179
 
166
180
  ### Collections (`shared/collections.ts`)
167
181
 
168
182
  ```typescript
169
- import { defineCollections } from 'ugly-app/shared';
170
- import type { Note } from './types';
183
+ import { defineCollections, InferDocType } from 'ugly-app/shared';
184
+ import { z } from 'zod';
185
+
186
+ export const TodoSchema = z.object({
187
+ userId: z.string(),
188
+ text: z.string(),
189
+ done: z.boolean(),
190
+ });
191
+ export type Todo = InferDocType<typeof TodoSchema>;
171
192
 
172
193
  export const collections = defineCollections({
173
- note: {
174
- type: {} as Note,
194
+ todo: {
195
+ schema: TodoSchema,
175
196
  meta: { cache: true, trackable: true, public: false, cascadeFrom: null },
176
197
  },
177
- user: {
178
- type: {} as User,
179
- meta: { cache: true, trackable: false, public: false, cascadeFrom: null },
180
- },
181
198
  });
182
199
  ```
183
200
 
184
- Each collection definition has:
185
- - `type` — phantom field for TypeScript type inference (never read at runtime)
186
- - `meta` — runtime metadata:
187
- - `cache` — enable in-memory LRU caching for `getDoc`; `setDoc`/`deleteDoc` invalidate it
188
- - `trackable` — allow real-time `trackDoc`/`trackDocs` subscriptions via Change Streams
189
- - `public`allows client reads via `getDoc`/`trackDoc`/`trackDocs` without auth
190
- - `cascadeFrom` parent collection name for cascade deletes (or `null`)
191
- - `trackKeys?`fields usable as NATS routing keys for `trackDocs`
192
- - `onDelete?` — optional async callback invoked on document deletion
201
+ `CollectionMeta`:
202
+ - `cache` — read `getDoc` through an LRU cache; writes invalidate it.
203
+ - `trackable` — enables real-time `trackDoc` / `trackDocs` via NATS.
204
+ - `public` — allow unauthenticated client reads.
205
+ - `cascadeFrom` — parent collection for cascade deletes.
206
+ - `trackKeys?`fields usable as NATS routing keys for `trackDocs`.
207
+ - `search?: { fields, language? }` PostgreSQL full-text search columns.
208
+ - `vector?: { dimensions, source }` Qdrant vector index over the named JSONB path.
209
+
210
+ All documents extend `DBObject`: `{ _id, version, created, updated }`. Use `dbDefaults()` to stamp the latter three on inserts.
193
211
 
194
- All documents extend `DBObject`: `{ _id: string, version: number, created: Date, updated: Date }`.
212
+ After schema changes, run `npm run db:schema-gen` and then `npm run db:migrate`. The app refuses to start when drift is detected (set `SCHEMA_CHECK_SKIP=true` only as a last resort).
195
213
 
196
214
  ### Pages (`shared/pages.ts`)
197
215
 
@@ -199,922 +217,513 @@ All documents extend `DBObject`: `{ _id: string, version: number, created: Date,
199
217
  import { definePage, definePages } from 'ugly-app/shared';
200
218
 
201
219
  export const pages = definePages({
202
- '': definePage<{}>({ auth: false }), // /
203
- 'note/:noteId': definePage<{ noteId: string }>(), // /note/abc
204
- 'search': definePage<{ q?: string }>({ auth: false }),// /search?q=foo
205
- 'blog/*slug': definePage<{ slug: string }>({ ssr: true }),// /blog/any/path
220
+ '': definePage<{}>({ auth: false }), // /
221
+ 'user/:userId': definePage<{ userId: string }>(), // /user/abc
222
+ 'search': definePage<{ q?: string }>({ auth: false }), // /search?q=foo
223
+ 'blog/*slug': definePage<{ slug: string }>({ ssr: true }), // /blog/any/path
206
224
  });
207
225
  export type AppPages = typeof pages;
208
226
  ```
209
227
 
210
- - `:param` single path segment; `*param` greedy rest (captures slashes)
211
- - `auth: true` (default) — requires login; `auth: false` public
212
- - `ssr: true` server-renders the page (supply `renderPage` to `setPages()`)
213
- - The generic type parameter on `definePage<Params>()` types the route's params it's phantom (never set at runtime)
228
+ - `:param` matches a single path segment; `*param` is greedy (captures slashes).
229
+ - The generic on `definePage<Params>()` is **phantom** never set at runtime, used for client-side type inference.
230
+ - `auth` defaults to `true`. `ssr` defaults to `false`.
231
+ - Query-string params are declared in `Params` but never appear in the path template.
214
232
 
215
233
  ---
216
234
 
217
235
  ## Client
218
236
 
219
- ### Entry point (`client/main.tsx`)
220
-
221
- ```tsx
222
- import { createRoot } from 'react-dom/client';
223
- import { AppProvider, createSocket, LoginPopup } from 'ugly-app/client';
224
- import { requests } from '../shared/api';
225
- import { RouterProvider } from './router';
226
- import App from './App';
227
-
228
- const token = (window as unknown as { __AUTH_TOKEN__?: string }).__AUTH_TOKEN__;
229
- const root = createRoot(document.getElementById('root')!);
230
- const loginPopup = <LoginPopup onSuccess={() => window.location.reload()} />;
231
-
232
- if (!token) {
233
- root.render(
234
- <RouterProvider fallback={<div>404</div>} loginFallback={loginPopup} isAuthenticated={() => false}>
235
- <App socket={null} />
236
- </RouterProvider>,
237
- );
238
- } else {
239
- const userId = JSON.parse(atob(token.split('.')[1]!)).sub as string;
240
- const socket = createSocket({ requests, url: '/rpc' });
241
- socket.connect(token).then((user) => {
242
- root.render(
243
- <RouterProvider fallback={<div>404</div>} loginFallback={loginPopup} isAuthenticated={() => true}>
244
- <AppProvider socket={socket} userId={userId} user={user}>
245
- <App socket={socket} />
246
- </AppProvider>
247
- </RouterProvider>,
248
- );
249
- });
250
- }
251
- ```
252
-
253
- ### `createSocket()`
254
-
255
- Creates a typed WebSocket client for RPC communication.
256
-
257
- ```typescript
258
- const socket = createSocket({ requests, url: '/rpc' });
259
- await socket.connect(token); // returns UserBase
260
- ```
261
-
262
- **`AppSocket` methods:**
263
-
264
- | Method | Description |
265
- |--------|-------------|
266
- | `connect(token)` | Authenticate and connect. Returns the user object |
267
- | `request(name, input)` | Invoke a typed request (query or mutation) |
268
- | `getDoc(collection, id)` | Fetch a single document |
269
- | `getDocs(collection, filter?, opts?)` | Query documents (filter, sort, limit, skip) |
270
- | `getQuery(collection, pipeline, opts?)` | Run an aggregation pipeline |
271
- | `trackDoc(collection, id, cb)` | Subscribe to real-time doc changes. Returns unsubscribe fn |
272
- | `trackDocs(collection, params, cb)` | Subscribe to query results (`keys`, `filter`, `sort`, `limit`, `skip`). Returns unsubscribe fn |
273
- | `uploadFile(file, key)` | Upload a file via presigned URL |
274
- | `emit(type, data)` | Send a fire-and-forget message over WebSocket |
275
- | `send(type, data, timeout?)` | Send a message and wait for a response |
276
- | `waitForConnection(timeout?)` | Wait until the socket is connected |
277
- | `connectionState` | Current state: `'connecting'` \| `'connected'` \| `'reconnecting'` \| `'disconnected'` \| `'idle-disconnected'` |
278
- | `disconnect()` | Close the connection |
279
-
280
- **`createSocket()` options:**
281
-
282
- | Option | Description |
283
- |--------|-------------|
284
- | `requests` | The requests registry from `shared/api.ts` |
285
- | `url?` | WebSocket path (default: `'/rpc'`) |
286
- | `buildId?` | Build identifier sent on connect |
287
- | `onCustomMessage?` | Handle custom server-pushed messages |
288
- | `getUrlParams?` | Extra query params appended to the WebSocket URL |
289
- | `messageReviver?` | JSON reviver for incoming messages (e.g. Date parsing) |
290
-
291
- ### `createHttpClient()`
292
-
293
- Creates a typed HTTP client for RPC communication (no WebSocket needed).
294
-
295
- ```typescript
296
- import { createHttpClient } from 'ugly-app/client';
297
-
298
- const client = createHttpClient({ requests, token: 'eyJ...', baseUrl: '' });
299
- const result = await client.request('getMe', {});
300
- ```
301
-
302
- | Option | Description |
303
- |--------|-------------|
304
- | `requests` | The requests registry from `shared/api.ts` |
305
- | `token?` | Bearer token for authenticated requests |
306
- | `baseUrl?` | URL prefix (default: `''` — relative paths: `POST /api/:name`) |
237
+ ### `bootstrapApp()`
307
238
 
308
- ### `AppProvider`
309
-
310
- Wraps your app with context for `useApp()`. Provides user info, socket access, popup management, async loading overlay, splash screen, and localization.
239
+ The recommended entrypoint. Handles auth detection, socket creation, optional auto-login through the ugly.bot iframe, and provider wiring.
311
240
 
312
241
  ```tsx
313
- <AppProvider
314
- socket={socket}
315
- userId={userId}
316
- user={user}
317
- splashScreen={<MySplash />} // optional
318
- loadingOverlay={<MyLoader />} // optional — shown during runAsync()
319
- localizer={(key, params) => t(key)} // optional i18n function
320
- >
321
- {children}
322
- </AppProvider>
242
+ // client/main.tsx
243
+ import { bootstrapApp, FeedbackButton } from 'ugly-app/client';
244
+ import { requests } from '../shared/api';
245
+ import { RouterProvider, RouterView } from './router';
246
+ import './styles.css';
247
+
248
+ bootstrapApp({
249
+ requests,
250
+ RouterProvider,
251
+ render: () => (
252
+ <>
253
+ <RouterView />
254
+ <FeedbackButton />
255
+ </>
256
+ ),
257
+ strings: { /* optional StringsProviderConfig */ },
258
+ });
323
259
  ```
324
260
 
325
- **`useApp()` returns:**
261
+ `BootstrapAppOptions`:
326
262
 
327
263
  | Field | Description |
328
264
  |-------|-------------|
329
- | `userId` | Current user ID |
330
- | `user` | Current `UserBase` object |
331
- | `socket` | The `AppSocket` instance |
332
- | `showPopup(content)` | Show a popup (returns popup ID) |
333
- | `hidePopup(id)` | Hide a specific popup |
334
- | `hideAllPopups()` | Dismiss all popups |
335
- | `runAsync(label, fn, opts?)` | Run an async operation with loading overlay |
336
- | `splashDone(step)` | Mark a splash screen step as complete |
337
- | `localizer(key, params?)` | Localize a string key |
338
-
339
- `useAppOptional()` returns the same context or `null` if outside `<AppProvider>`.
265
+ | `requests` | Your `RequestRegistry` (merged with framework requests internally). |
266
+ | `messages?` | Your `MessageRegistry` (merged with framework messages). |
267
+ | `RouterProvider` | The `RouterProvider` returned from `createRouter()`. |
268
+ | `render` | Callback returning the app's UI tree (typically `<RouterView /> + <FeedbackButton />`). |
269
+ | `root?` | Root element / selector (default `'#root'`). |
270
+ | `fallback?` | UI for unmatched routes (default: tiny "404"). |
271
+ | `socketUrl?` | Override the WebSocket path (default `/rpc`). |
272
+ | `strings?` | Localization config when present, wraps the tree with `<StringsProvider>`. |
273
+ | `keyboard?: false` | Disable the framework `<KeyboardProvider>` wrapper. |
340
274
 
341
- `useLocalizer()` returns the localizer function (falls back to identity if outside provider).
275
+ `bootstrapApp` reads `window.__AUTH_TOKEN__` (injected by the server). If absent, it renders unauthenticated and lets the router show `loginFallback` for auth-guarded pages. If present, it connects the socket, mounts `<AppProvider>`, and renders.
342
276
 
343
- ### Router
344
-
345
- #### Setup (`client/router.ts`)
277
+ ### Routing — `createRouter()`
346
278
 
347
279
  ```typescript
280
+ // client/router.ts
348
281
  import { createRouter } from 'ugly-app/client';
349
282
  import { pages } from '../shared/pages';
350
283
  import { allPages } from './allPages';
351
284
 
352
- export const { RouterProvider, RouterView, useRouter } = createRouter({ pages, allPages });
285
+ export const { RouterProvider, RouterView, useRouter } = createRouter({
286
+ pages,
287
+ allPages,
288
+ });
353
289
  ```
354
290
 
355
- `createRouter()` returns three things:
356
- - **`RouterProvider`** — wrap your app. Props: `children`, `fallback?` (shown before first route resolves), `loginFallback?` (shown for auth-guarded pages when unauthenticated), `isAuthenticated?` (function returning boolean)
357
- - **`RouterView`** — renders the active page with animated transitions. Props: `durationMs?`, `easing?`, `transitionComponent?`, `renderPage?`
358
- - **`useRouter()`** — hook returning the router context
291
+ `createRouter` returns:
359
292
 
360
- #### Page map (`client/allPages.ts`)
293
+ - **`RouterProvider`** props: `children`, `fallback?`, `loginFallback?`, `isAuthenticated?`, `autoLogin?`. Manages route state, browser history, popups, and `<AutoLoginGate>` (silent iframe-based login check against ugly.bot).
294
+ - **`RouterView`** — renders the active page with animated transitions. Props: `durationMs?`, `easing?`, `transitionComponent?` (replaces `ViewFlipper`), `renderPage?` (sync alternative to `allPages` loaders).
295
+ - **`useRouter()`** — returns the router context (see below).
296
+
297
+ ### Page map — `lazyPage` / `lazyPageLoader`
361
298
 
362
299
  ```typescript
300
+ // client/allPages.ts
363
301
  import { lazyPage, lazyPageLoader } from 'ugly-app/client';
364
302
  import type { PageMap } from 'ugly-app/shared';
365
303
  import type { AppPages } from '../shared/pages';
366
304
 
367
305
  export const allPages = {
368
306
  ['']: lazyPage(() => import('./pages/HomePage')),
369
- ['note/:noteId']: lazyPage(() => import('./pages/NotePage')),
370
- ['search']: lazyPage(() => import('./pages/SearchPage')),
371
- ['slow']: lazyPageLoader(() => import('./pages/SlowPageLoader')),
307
+ ['user/:userId']: lazyPage(() => import('./pages/UserPage')),
308
+ ['slow/:id']: lazyPageLoader(() => import('./pages/SlowPageLoader')),
372
309
  } satisfies PageMap<AppPages>;
373
310
  ```
374
311
 
375
- - **`lazyPage(factory)`** — lazy-imports a default-exported React component. The component receives route params as props.
376
- - **`lazyPageLoader(factory)`** — lazy-imports an async loader function `(params) => Promise<ReactElement>` for routes that need data fetching before render. The loader file is the chunk boundary it can statically import its page component.
312
+ - **`lazyPage(factory)`** — lazy-imports a default-exported `React.ComponentType<Params>`. The page receives route params as props.
313
+ - **`lazyPageLoader(factory)`** — lazy-imports an async loader `(params) => Promise<ReactElement>`. Use when a route needs data fetching before render. The loader file is the chunk boundary, so it can statically import its page component.
377
314
 
378
- **`lazyPageLoader` example:**
315
+ Example loader:
379
316
 
380
317
  ```typescript
381
318
  // pages/SlowPageLoader.tsx
382
- import SlowPage from './SlowPage'; // static import OK — same chunk
319
+ import SlowPage from './SlowPage';
383
320
  export default async function PageLoader({ id }: { id: string }) {
384
321
  const data = await fetchSlowData(id);
385
322
  return <SlowPage {...data} />;
386
323
  }
387
324
  ```
388
325
 
389
- #### Navigation with `useRouter()`
326
+ ### Navigation `useRouter()`
390
327
 
391
328
  ```typescript
392
- const { push, replace, back, current, openPopup, closePopup, closeAllPopups } = useRouter();
329
+ const { current, push, replace, back, openPopup, closePopup, closeAllPopups } = useRouter();
393
330
 
394
- push('note/:noteId', { noteId: '123' }); // pushes /note/123
395
- replace('search', { q: 'hello' }); // replaces with /search?q=hello
396
- back(); // browser back
331
+ push('user/:userId', { userId: '123' }); // /user/123
332
+ replace('search', { q: 'hello' }); // /search?q=hello
333
+ back(); // browser history back
397
334
 
398
- // current route state
399
- current.routeName; // e.g. 'note/:noteId'
400
- current.params; // e.g. { noteId: '123' }
335
+ current.routeName; // typed union of all route keys
336
+ current.params; // typed params for the current route
401
337
  ```
402
338
 
403
- All route names and params are fully typed based on your `pages` definition.
339
+ All route names and params are fully typed against `pages`. Internally `push` / `replace` are no-ops when `buildUrl()` produces a URL that doesn't match a registered route (and emit a `console.error`).
404
340
 
405
- #### Popups
341
+ ### Popups — `openPopup()`
406
342
 
407
- Always use `useRouter().openPopup()` never build custom fixed overlays.
343
+ Always use `useRouter().openPopup()` for modals, sheets, and menus. The router owns the popup layer, manages the spring animation, and stacks popups z-index-correctly.
408
344
 
409
345
  ```tsx
410
346
  const { openPopup } = useRouter();
411
347
 
412
348
  const handle = openPopup(<MyContent />, {
413
- mode: 'transient', // 'block' | 'transient' | 'contextMenu'
414
- slideFrom: 'bottom', // 'left' | 'right' | 'top' | 'bottom' | 'none'
415
- onClose: () => {}, // called when popup closes
416
- containerStyle: {}, // CSS for the content wrapper
417
- backgroundStyle: {}, // CSS for the backdrop
349
+ mode: 'transient', // 'block' (default) | 'transient' | 'contextMenu'
350
+ slideFrom: 'bottom', // 'left' | 'right' | 'top' | 'bottom' | 'none' (default)
351
+ onClose: () => {},
352
+ containerStyle: { /* CSS for the content wrapper */ },
353
+ backgroundStyle: { /* CSS for the backdrop */ },
418
354
  animConfig: { duration: 300, easing: myEasingFn },
419
- renderLayer: (props) => <CustomLayer {...props} />, // fully replace the popup renderer
355
+ renderLayer: (props) => <CustomLayer {...props} />, // fully replace the layer renderer
420
356
  });
421
357
 
422
358
  handle.hide(); // dismiss programmatically
423
359
  ```
424
360
 
425
- Popup modes:
426
- - **`block`** (default) — dark backdrop (40% opacity), clicking backdrop does NOT dismiss
427
- - **`transient`** — light backdrop (20% opacity), clicking backdrop dismisses
428
- - **`contextMenu`** — same as transient, intended for menus and pickers
429
-
430
- `renderLayer` receives `{ content, spring, hide }` — `spring` is an animated value from 0 to 1, `hide` is a function to close the popup.
361
+ Modes:
362
+ - **`block`** (default) — 40% opacity backdrop, **does not** dismiss on backdrop click.
363
+ - **`transient`** — 20% opacity backdrop, **dismisses** on backdrop click.
364
+ - **`contextMenu`** — same as transient, intended for menus and pickers.
431
365
 
432
- #### `Link` component
366
+ `renderLayer` receives `{ content, spring, hide }` — `spring` is an `AnimatedValueRef` driving 0 → 1, `hide` closes the popup.
433
367
 
434
- ```tsx
435
- import { Link } from 'ugly-app/client';
436
-
437
- <Link router={router} to="note/:noteId" params={{ noteId: '123' }}>
438
- View Note
439
- </Link>
440
- ```
368
+ ### `AppProvider` & `useApp()`
441
369
 
442
- Renders an `<a>` tag with the correct `href`. Intercepts clicks for client-side navigation (ctrl/cmd+click opens in new tab).
443
-
444
- #### Animation system
445
-
446
- Built-in animation primitives for transitions and popups. `Animated.div` and `Animated.span` accept `AnimatedStyle` — a style object where any CSS property can be an `AnimatedValueRef` or a `TransformedValue` instead of a static value.
370
+ `bootstrapApp` mounts `<AppProvider>` automatically after socket connect. Use `useApp()` inside any page to access the active user and socket.
447
371
 
448
372
  ```typescript
449
- import {
450
- Animated,
451
- createAnimatedValue,
452
- useAnimatedValue,
453
- easingFunctions,
454
- FadeIn,
455
- SlideFromBottom,
456
- SlideFromRight,
457
- } from 'ugly-app/client';
458
-
459
- // Create an animated value (0->1 spring)
460
- const spring = createAnimatedValue(0);
461
- spring.start(1, { duration: 300, easing: easingFunctions.easeOut });
462
-
463
- // Use in components — animated properties bypass React re-renders via direct DOM mutation
464
- <Animated.div style={{ opacity: spring.to((v) => String(v)) }}>
465
- Content
466
- </Animated.div>
467
-
468
- // Hook version — creates and manages an animated value in a component
469
- const anim = useAnimatedValue(0);
470
-
471
- // Pre-built entrance animations (wrap any children)
472
- <FadeIn>{children}</FadeIn>
473
- <SlideFromBottom>{children}</SlideFromBottom>
474
- <SlideFromRight>{children}</SlideFromRight>
373
+ const {
374
+ userId, // current user id
375
+ user, // UserBase doc
376
+ socket, // AppSocket — typed RPC client
377
+ uglyBotSocket, // optional UglyBotSocket for direct platform calls (STT/TTS, etc.)
378
+ showPopup, // legacy popup API (prefer useRouter().openPopup)
379
+ hidePopup,
380
+ hideAllPopups,
381
+ runAsync, // runAsync('label', async () => { ... }) shows loading overlay
382
+ splashDone, // mark a splash-screen step complete
383
+ localizer, // (key, params?) => string alias for useLocalizer
384
+ } = useApp();
475
385
  ```
476
386
 
477
- **`AnimatedValueRef` API:**
478
-
479
- | Member | Description |
480
- |--------|-------------|
481
- | `current` | Current numeric value |
482
- | `target` | Target value |
483
- | `isAnimating` | Whether an animation is in progress |
484
- | `start(target, config?)` | Animate to target. Returns `Promise<void>` |
485
- | `set(value)` | Jump to value immediately (no animation) |
486
- | `stop()` | Cancel the current animation |
487
- | `subscribe(cb)` | Subscribe to value changes. Returns unsubscribe fn |
488
- | `to(transform)` | Create a transformed value for use in `Animated` styles |
489
-
490
- **`AnimConfig`:** `{ duration?, easing?, onRest?, onUpdate?, immediate? }`
387
+ `useAppOptional()` returns `null` outside the provider; `useLocalizer()` returns a localizer that falls back to identity.
491
388
 
492
- Available easings: `easingFunctions.linear`, `easeIn`, `easeOut`, `easeInOut`, `springGentle`, `springSnappy`, `springBouncy`, `slow`.
389
+ ### `Link` component
493
390
 
494
- Additional animation hooks:
495
- - `useAnimatedTransition` / `useAnimatedPresence` mount/unmount transitions with phase tracking
496
- - `useScrollAnimation` / `useStaggerAnimation` — scroll-driven and staggered entrance animations
391
+ ```tsx
392
+ import { Link } from 'ugly-app/client';
497
393
 
498
- #### Screenshot capture
394
+ <Link router={router} to="user/:userId" params={{ userId: '123' }}>View profile</Link>
395
+ ```
499
396
 
500
- ```typescript
501
- import { captureScreenshot } from 'ugly-app/client';
397
+ Renders an `<a>` with the right `href`, intercepts clicks for client-side navigation, and lets `ctrl/cmd+click` open in a new tab.
502
398
 
503
- const dataUrl = await captureScreenshot(); // captures the current viewport
504
- ```
399
+ ### Direct socket access
505
400
 
506
- #### UI components
401
+ `AppSocket` is exposed through `useApp().socket`. Common methods:
507
402
 
508
- `ugly-app/client` exports a set of built-in UI components:
403
+ | Method | Description |
404
+ |--------|-------------|
405
+ | `request(name, input)` | Invoke a typed RPC handler. |
406
+ | `getDoc(collection, id)` | Server-mediated doc fetch. |
407
+ | `getDocs(collection, filter?, opts?)` | Filtered query. |
408
+ | `trackDoc(collection, id, cb)` | Live subscription — returns unsubscribe. |
409
+ | `trackDocs(collection, params, cb)` | Live filtered subscription. |
410
+ | `uploadFile(file, key)` | Presigned upload to the `temp` bucket. |
411
+ | `connectionState` | `'connecting' \| 'connected' \| 'reconnecting' \| 'disconnected' \| 'idle-disconnected'`. |
412
+ | `disconnect()` | Close the connection. |
509
413
 
510
- `Button`, `Card`, `EnumInput`, `Header`, `Image`, `Input`, `Modal`, `PageLayout`, `Panel`, `PopupPanel`, `Pressable`, `ScrollView`, `SettingGroup`, `Text`, `Toast`, `View`, `ResponsiveGrid`, `TabPicker`, `HeaderTabPicker`, `TabContent`, `TabContentAllActive`
414
+ For pure HTTP (no WebSocket), use `createHttpClient({ requests, token?, baseUrl? })`.
511
415
 
512
416
  ---
513
417
 
514
418
  ## Auth
515
419
 
516
- Auth uses HttpOnly cookies with server-side JWT injection. No localStorage needed.
420
+ ugly-app uses HttpOnly cookies and server-side JWT injection no `localStorage`, no client-side token handling.
517
421
 
518
422
  **Flow:**
519
423
 
520
- 1. User opens `<LoginPopup />` which redirects to OAuth at `https://ugly.bot/oauth`
521
- 2. Browser posts the auth code to `POST /auth/verify` server sets `auth_token` HttpOnly cookie
522
- 3. On every page load the server refreshes the cookie and injects the token into HTML:
523
- ```html
524
- <script>window.__AUTH_TOKEN__ = "eyJ..."</script>
525
- ```
526
- 4. Client reads `window.__AUTH_TOKEN__` synchronously and connects the WebSocket
527
-
528
- **Logout:**
424
+ 1. Unauthenticated user lands on a page; the optional `<AutoLoginGate>` opens a hidden iframe to `https://ugly.bot/iframe-auth` to check for an existing platform session.
425
+ 2. If found, the iframe posts an OAuth code back; the client POSTs to `/auth/verify`; the server exchanges it through `uglyBotAuthProvider`, sets the `auth_token` HttpOnly cookie, and the page reloads authenticated.
426
+ 3. If not found (or timeout after 4 s), `<RouterProvider>` shows `loginFallback` for auth-required routes. The default fallback is `<LoginPopup>`, which opens `https://ugly.bot/oauth` in a popup window.
427
+ 4. On every authenticated request, the server verifies the cookie token by calling `${UGLY_BOT_URL}/verify` and injects `window.__AUTH_TOKEN__` into the HTML so the client can pass it on the WebSocket handshake.
529
428
 
530
- ```typescript
531
- await fetch('/auth/logout', { method: 'POST' });
532
- window.location.reload();
533
- ```
429
+ **Token-in-URL embed mode:** any GET request with `?token=<JWT>` will, if the token verifies against ugly.bot, set the cookie and 302-redirect to the same URL without the token — letting any page be embedded in an iframe.
534
430
 
535
- **Built-in auth endpoints:**
431
+ **Built-in routes** (mounted on every app):
536
432
 
537
433
  | Endpoint | Description |
538
434
  |----------|-------------|
539
- | `POST /auth/verify` | Exchange OAuth code for a session cookie |
540
- | `POST /auth/logout` | Clear the auth cookie |
541
- | `GET /auth/token` | Refresh and return the current token |
542
- | `GET /auth/url` | Get the OAuth popup URL |
435
+ | `POST /auth/verify` | Exchange an OAuth `code` for a session cookie. |
436
+ | `POST /auth/logout` | Clear the cookie. |
437
+ | `GET /auth/token` | Refresh and return the current token (used by clients that need an explicit token). |
438
+ | `GET /auth/url` | Return the OAuth popup URL. |
543
439
 
544
- **Custom auth provider:**
440
+ **Custom provider:**
545
441
 
546
442
  ```typescript
547
443
  configurator.setAuth({
548
- verify: async (code: string) => ({ userId: '...', email: '...' }),
549
- authUrl: (origin: string) => 'https://my-oauth.com/authorize?...',
444
+ verify: async (code) => ({ userId: '...', token: 'platform-issued-jwt' }),
445
+ authUrl: (origin) => `https://my-oauth.example/authorize?origin=${origin}`,
550
446
  registerRoutes: (router) => { /* optional extra routes */ },
551
447
  });
552
448
  ```
553
449
 
554
- The `AuthProvider` interface:
450
+ **Server-side helpers** (`ugly-app`):
555
451
 
556
- ```typescript
557
- interface AuthProvider {
558
- verify(code: string): Promise<{ userId: string; email?: string; phone?: string; token?: string }>;
559
- authUrl(origin: string): string;
560
- registerRoutes?(router: express.Router): void;
561
- }
562
- ```
452
+ - `verifyToken(token)` — verifies a token against ugly.bot and returns the `userId`.
453
+ - `getRequestUser(req)` — synchronous decode of the per-project `UGLY_PROJECT_TOKEN` cookie set by ugly.bot's wake-on-traffic gate. Returns `{ userId } | null` without a network round-trip; safe to use as the primary auth check in deployed-app handlers.
563
454
 
564
455
  ---
565
456
 
566
- ## Database (`TypedDB`)
457
+ ## Database `TypedDB`
567
458
 
568
- Access via `app.db` or by importing `createTypedDB` / `getMongoClient` from `'ugly-app'`.
569
-
570
- All methods accept either a `CollectionDef` object (from `defineCollections`) or a plain collection name string as the first argument.
459
+ Access via `app.db` or via `import { app } from './your-app-module'`. All methods accept a `CollectionDef` (from `defineCollections`) or a plain collection name string.
571
460
 
572
461
  ### Writing
573
462
 
574
463
  ```typescript
575
- // Insert or replace a document
576
- await db.setDoc(collections.note, doc);
577
- await db.setDoc(collections.note, doc, { skipIfExists: true });
578
-
579
- // Partial update — only specified fields (supports dot-notation paths)
580
- await db.setDocFields(collections.note, id, { title: 'New title' });
464
+ await db.setDoc(collections.note, doc); // upsert
465
+ await db.setDoc(collections.note, doc, { skipIfExists: true }); // insert-only
581
466
 
582
- // Partial update returns null if document doesn't exist (no error)
583
- const doc = await db.setDocFieldsOrIgnore(collections.note, id, { title });
467
+ await db.setDocFields(collections.note, id, { title: 'New' }); // partial; throws if missing
468
+ await db.setDocFieldsOrIgnore(collections.note, id, { title }); // returns null if missing
469
+ await db.setDocFieldsOrCreate(collections.note, id, { title }, default); // upsert with default
584
470
 
585
- // Partial update creates the document if it doesn't exist (obj = default doc for insert)
586
- await db.setDocFieldsOrCreate(collections.note, id, { title }, defaultDoc);
587
-
588
- // MongoDB update operators ($inc, $addToSet, $pull, $unset, $set)
589
- await db.setDocOp(collections.note, id, { $inc: { views: 1 } });
590
- await db.setDocOpOrIgnore(collections.note, id, { $inc: { views: 1 } }); // no error if missing
471
+ await db.setDocOp(collections.note, id, { $inc: { views: 1 } }); // MongoDB-style ops
472
+ await db.setDocOpOrIgnore(collections.note, id, { $inc: { views: 1 } });
591
473
  ```
592
474
 
475
+ Supported update operators: `$inc`, `$addToSet`, `$pull`, `$unset`, `$set`. All keys are dot-notation, fully typed against the collection's schema.
476
+
593
477
  ### Reading
594
478
 
595
479
  ```typescript
596
- const note = await db.getDoc(collections.note, id);
597
- const notes = await db.getDocs(collections.note, { userId }, { sort: { created: -1 }, limit: 20 });
480
+ const doc = await db.getDoc(collections.note, id);
481
+ const docs = await db.getDocs(collections.note, { userId }, { sort: { created: -1 }, limit: 20 });
598
482
 
599
- // Aggregation pipeline
483
+ // Typed SQL-native query API (preferred for new code)
484
+ const notes = await db.find(collections.note, { userId, done: { $ne: true } }, { sort: { created: -1 }, limit: 20 });
485
+ const count = await db.findCount(collections.note, { userId });
486
+ const sample = await db.findRandom(collections.note, { userId }, 5);
487
+
488
+ // Aggregation pipelines (legacy / advanced)
600
489
  const results = await db.getQuery<MyResult>('note', pipeline, { skip, limit });
601
- const count = await db.getQueryCount('note', pipeline);
602
- const raw = await db.getQueryRaw<T>('note', pipeline);
490
+ const total = await db.getQueryCount('note', pipeline);
603
491
 
604
- // Dynamic/untyped access (when collection name is a runtime string)
605
- const doc = await db.rawGetDoc(collectionName, id);
606
- const docs = await db.rawGetDocs(collectionName, filter);
492
+ // Dynamic / untyped access when the collection name is a runtime string
493
+ await db.rawGetDoc('note', id);
494
+ await db.rawGetDocs('note', filter);
607
495
  ```
608
496
 
609
497
  ### Deleting
610
498
 
611
499
  ```typescript
612
- await db.deleteDoc(collections.note, id); // single doc (cascades via cascadeFrom)
613
- await db.deleteQuery(collections.note, { userId }); // bulk delete by filter (cascades + calls onDelete)
500
+ await db.deleteDoc(collections.note, id); // cascade-deletes children
501
+ await db.deleteWhere(collections.note, { userId }); // typed bulk delete
502
+ await db.deleteQuery(collections.note, { userId }); // legacy untyped bulk delete
503
+ ```
504
+
505
+ Pass `deleteHandlers` as the 5th argument to `createApp` to run per-collection `onDelete` callbacks.
506
+
507
+ ### Search
508
+
509
+ ```typescript
510
+ // Full-text search — requires `search: { fields, language? }` on the collection
511
+ const hits = await db.searchDocs(collections.note, 'react hooks', { limit: 10 });
512
+
513
+ // Vector search — requires `vector: { dimensions, source }` (uses Qdrant)
514
+ const similar = await db.vectorSearch(collections.note, embeddingVector, { limit: 10 });
614
515
  ```
615
516
 
616
517
  ### Caching
617
518
 
618
519
  ```typescript
619
- const cached = db.cacheGet<MyType>(key);
520
+ db.cacheGet<MyType>(key);
620
521
  db.cacheSet(key, value, ttlMs);
621
- db.cacheDelete(key);
622
- const key = db.cacheKey('prefix', id); // generate a cache key
522
+ db.cacheDelete(key); // broadcasts invalidation via NATS
523
+ const k = db.cacheKey('prefix', id);
623
524
  ```
624
525
 
625
526
  ### Helpers
626
527
 
627
528
  ```typescript
628
- import { createUserHelper } from 'ugly-app';
629
- import { dbDefaults } from 'ugly-app/shared';
529
+ import { createUserHelper, dbDefaults } from 'ugly-app';
630
530
 
631
- // dbDefaults() returns { version: 1, created: new Date(), updated: new Date() }
632
- const doc = { id: newId(), ...dbDefaults(), title: 'Hello' };
531
+ const newDoc = { _id: crypto.randomUUID(), ...dbDefaults(), title: 'Hi' };
532
+ // ^^^^^^^^^^^^^^^ { version: 1, created, updated }
633
533
 
634
- // createUserHelper — typed user CRUD with get, set, update methods
635
534
  const userHelper = createUserHelper<User>(collections.user);
636
535
  const user = await userHelper.get(db, userId);
637
- await userHelper.set(db, { id: userId, ...dbDefaults(), email });
638
536
  ```
639
537
 
640
- ### Indexes
538
+ ### Direct SQL & infra
641
539
 
642
- Indexes are defined on the collection definition in `shared/collections.ts`:
540
+ Imports available from `ugly-app`:
643
541
 
644
- ```typescript
645
- export const collections = defineCollections({
646
- note: {
647
- type: {} as Note,
648
- meta: { cache: true, trackable: true, public: false, cascadeFrom: null },
649
- indexes: [
650
- { fields: { userId: 1, created: -1 } },
651
- { fields: { title: 1 }, unique: true },
652
- ],
653
- },
654
- });
655
- ```
656
-
657
- After adding or modifying indexes:
658
- 1. Run `npm run db:schema-gen` to generate a migration file
659
- 2. Run `npm run db:migrate` to apply the migration
542
+ - `pgQuery(sql, params?)` — parameterized SQL on the data proxy.
543
+ - `ensureTable(...)`, `tableExists(...)`, `ensureSearchColumn(...)`.
544
+ - `ensureQdrantCollection(...)`, `upsertVector(...)`, `searchVectors(...)`, `deleteVector(...)`, `deleteQdrantCollection(...)`.
545
+ - `connectNats()`, `natsPublish(subject, payload)`, `natsSubscribe(subject, cb)`, `ensureKvBucket(name, opts)`, `jsPublish(...)`, `jsConsumerCreate(...)`, `jsConsumerConsume(...)`.
546
+ - `subscribeCollection`, `subscribeDoc`, `subscribeDocKey` NATS subjects emitted by the data proxy on writes.
660
547
 
661
548
  ---
662
549
 
663
550
  ## AI
664
551
 
665
- ### Text generation
552
+ AI calls are proxied through ugly.bot — your app never holds an AI provider key. Pass `UGLY_BOT_TOKEN` in the environment and the framework handles routing, balance tracking, retries, and per-user billing.
553
+
554
+ ### Server-side text generation
666
555
 
667
556
  ```typescript
668
557
  import { createTextGenClient } from 'ugly-app';
669
- const textGen = createTextGenClient();
558
+ const textGen = createTextGenClient(userId);
670
559
 
671
- const text = await textGen.generate(messages);
672
- const json = await textGen.generateJson(schema, messages); // Zod schema, retries on parse failure
673
- const result = await textGen.generateWithTools(messages, tools); // automatic tool-call loop
560
+ const text = await textGen.generate(messages, { model: 'gemini_2_5_flash' });
674
561
  ```
675
562
 
676
- | Provider | `provider` value | Default model | JSON | Tools | Vision |
677
- |----------|-----------------|---------------|------|-------|--------|
678
- | Together AI | `'together'` | Llama-4-Maverick-17B-128E | yes | yes | yes |
679
- | Anthropic | `'claude'` | claude-sonnet-4-6 | yes | yes | yes |
680
- | OpenAI | `'openai'` | gpt-4o | yes | yes | yes |
681
- | Google | `'google'` | gemini-2.5-flash | yes | yes | yes |
682
- | Groq | `'groq'` | llama-3.3-70b-versatile | yes | yes | no |
683
- | Fireworks | `'fireworks'` | llama-v3p1-70b-instruct | yes | yes | yes |
684
- | Kie.ai | `'kie'` | gemini-2.0-flash | yes | yes | yes |
563
+ Or call the framework `textGen` request directly:
564
+
565
+ ```typescript
566
+ import { uglyBotRequest } from 'ugly-app';
567
+ const { message } = await uglyBotRequest<{ message: { content: string } }>('textGen', {
568
+ model: 'gemini_2_5_flash',
569
+ messages: [{ role: 'user', content: 'Hello!' }],
570
+ options: { maxTokens: 512 },
571
+ });
572
+ ```
685
573
 
686
- Use `provider: 'auto'` (default) to let the system pick based on requirements.
574
+ Available models are exposed via `textGenModels` / `textGenModelData` from `ugly-app` the platform supports Claude, GPT, Gemini, Together, Groq, Fireworks, and Kie families.
687
575
 
688
- ### Image generation
576
+ ### Server-side image generation
689
577
 
690
578
  ```typescript
691
579
  import { createImageGenClient } from 'ugly-app';
692
- const imageGen = createImageGenClient();
580
+ const imageGen = createImageGenClient(userId);
693
581
 
694
- const url = await imageGen.generate(prompt, { width: 1024, height: 1024 });
582
+ const url = await imageGen.generate('A red panda eating noodles', { model: 'flux_schnell' });
695
583
  ```
696
584
 
697
- | Provider | `provider` value |
698
- |----------|-----------------|
699
- | Together AI (FLUX schnell) | `'together'` |
700
- | FAL (FLUX pro) | `'fal'` |
701
- | Google (Imagen 3) | `'google'` |
702
- | Wavespeed | `'wavespeed'` |
703
- | Kie.ai (Kolors) | `'kie'` |
585
+ `imageGenModels` / `imageGenModelData` enumerate available models (Together FLUX, FAL, Google Imagen, Wavespeed, Kie Kolors).
704
586
 
705
587
  ### Embeddings
706
588
 
707
589
  ```typescript
708
590
  import { createEmbeddingClient, cosineSimilarity } from 'ugly-app';
709
591
  const embeddings = createEmbeddingClient();
710
- const similarity = cosineSimilarity(vectorA, vectorB);
711
- ```
712
-
713
- ### Speech-to-text (STT)
714
-
715
- Server-side STT uses a provider registry pattern. Providers auto-register when their API key env var is set.
716
-
717
- ```typescript
718
- import { registerSTTProvider, selectSTTProvider, getAllSTTProviders } from 'ugly-app';
719
- ```
720
-
721
- **Built-in STT providers:**
722
-
723
- | Provider | Import | Env var | Mode |
724
- |----------|--------|---------|------|
725
- | Deepgram (nova-2) | `deepgramSTTProvider` | `DEEPGRAM_API_KEY` | Real-time streaming via WebSocket |
726
- | OpenAI Whisper | `openAIWhisperSTTProvider` | `OPENAI_API_KEY` | Batch (buffers audio, transcribes on stop) |
727
- | Groq Whisper | `groqWhisperSTTProvider` | `GROQ_API_KEY` | Batch |
728
-
729
- **STT provider interface:**
730
-
731
- ```typescript
732
- interface STTProvider {
733
- name: string;
734
- apiKeyEnv: string;
735
- connect(
736
- onTranscript: (result: STTTranscript) => void,
737
- onError: (err: string) => void,
738
- lang?: string,
739
- options?: STTConnectOptions,
740
- onUsage?: (report: STTUsageReport) => void,
741
- ): Promise<STTSession>;
742
- }
743
-
744
- interface STTSession {
745
- sendAudio(pcm16: Buffer): void; // PCM16 at 16kHz mono
746
- stop(): Promise<void>;
747
- }
748
-
749
- interface STTTranscript { text: string; isFinal: boolean; lang?: string; words?: STTWord[] }
750
- ```
751
-
752
- **Client-side hook:**
753
-
754
- ```typescript
755
- import { useSTT } from 'ugly-app/client';
756
- const { start, stop, transcript, isListening } = useSTT(socket, options);
757
- ```
758
-
759
- ### Text-to-speech (TTS)
760
-
761
- ```typescript
762
- import { registerTTSProvider, selectTTSProvider, azureTTSProvider } from 'ugly-app';
763
- ```
764
-
765
- **Built-in TTS provider:**
766
-
767
- | Provider | Import | Env vars |
768
- |----------|--------|----------|
769
- | Azure TTS | `azureTTSProvider` | `AZURE_TTS_KEY`, `AZURE_TTS_REGION` |
770
-
771
- **TTS provider interface:**
772
-
773
- ```typescript
774
- interface TTSProvider {
775
- name: string;
776
- apiKeyEnv: string;
777
- stream(text: string, voice: string, options?: TTSStreamOptions): AsyncGenerator<TTSChunk>;
778
- }
779
-
780
- interface TTSChunk {
781
- audio: Buffer; // PCM16, mono, 24kHz
782
- word?: string;
783
- startMs?: number;
784
- durationMs?: number;
785
- visemes?: TTSViseme[]; // When requestVisemes=true (for lip sync)
786
- }
787
- ```
788
-
789
- **Client-side hook:**
790
-
791
- ```typescript
792
- import { useTTS, AudioPlayer, AudioRecorder } from 'ugly-app/client';
592
+ const vector = await embeddings.embed('hello world');
593
+ const sim = cosineSimilarity(vectorA, vectorB);
793
594
  ```
794
595
 
795
596
  ### Web search
796
597
 
797
598
  ```typescript
798
- import { createWebSearchClient, registerWebSearchProvider } from 'ugly-app';
599
+ import { createWebSearchClient } from 'ugly-app';
799
600
  const search = createWebSearchClient(userId);
800
601
 
801
- const results = await search.search({ query: 'hello', limit: 10 });
802
- const summary = await search.summarize({ url: 'https://...' });
803
- const web = await search.enrichWeb({ query: 'topic' });
804
- const news = await search.enrichNews({ query: 'topic' });
602
+ await search.search({ query: 'react 19', limit: 10 });
603
+ await search.summarize({ url: 'https://...' });
604
+ await search.enrichWeb({ query: 'topic' });
605
+ await search.enrichNews({ query: 'topic' });
805
606
  ```
806
607
 
807
- **`WebSearchClient` methods:**
808
-
809
- | Method | Description |
810
- |--------|-------------|
811
- | `search({ query, limit? })` | Web search — returns `{ items, related? }` |
812
- | `summarize({ url?, text? })` | Summarize a URL or text — returns summary string |
813
- | `enrichWeb({ query })` | Enriched web results |
814
- | `enrichNews({ query })` | Enriched news results |
815
-
816
- Built-in providers: Kagi (`KAGI_API_KEY`) and UglyBot.
608
+ ### Client-side AI calls
817
609
 
818
- ### Custom providers
610
+ Calls from React components go through the framework RPC pipeline — no token plumbing in the browser:
819
611
 
820
612
  ```typescript
821
- import { registerTextGenProvider, registerImageGenProvider, registerEmbeddingProvider } from 'ugly-app';
613
+ import { callTextGen, callJsonGen, callImageGen } from 'ugly-app/client';
822
614
 
823
- registerTextGenProvider('myProvider', myTextGenImplementation);
824
- registerImageGenProvider('myProvider', myImageGenImplementation);
825
- registerEmbeddingProvider('myProvider', myEmbeddingImplementation);
615
+ const text = await callTextGen({ messages, model: 'gemini_2_5_flash' });
616
+ const json = await callJsonGen({ messages, schema, model: 'gemini_2_5_flash' });
617
+ const image = await callImageGen({ prompt: 'a corgi astronaut', model: 'flux_schnell' });
826
618
  ```
827
619
 
828
- ### Client-side AI calls
620
+ ### STT / TTS
829
621
 
830
- The client can call AI through the server proxy without managing tokens directly:
622
+ Speech goes **directly** from the browser to ugly.bot never proxied through your app server.
831
623
 
832
624
  ```typescript
833
- import { callTextGen, callJsonGen, callImageGen } from 'ugly-app/client';
625
+ import { useSTT, useTTS, AudioPlayer, AudioRecorder } from 'ugly-app/client';
834
626
 
835
- const text = await callTextGen(input);
836
- const json = await callJsonGen(input);
837
- const image = await callImageGen(input);
627
+ const { start, stop, transcript, isListening } = useSTT(socket, options);
628
+ const { speak, stop: stopTTS } = useTTS(socket, { voice: 'alloy' });
838
629
  ```
839
630
 
840
- These call `POST /ai/request` (same-origin, avoids CORS). The server verifies the user JWT and forwards to `https://ugly.bot` using the `UGLY_BOT_TOKEN` env var.
841
-
842
631
  ---
843
632
 
844
633
  ## Storage
845
634
 
635
+ S3-compatible. Two logical buckets:
636
+
637
+ - **`temp`** — short-lived uploads (presigned PUT from the browser).
638
+ - **`public`** — durable, served by CDN.
639
+
640
+ Server-side:
641
+
846
642
  ```typescript
847
- // Server-side put a file
848
- const url = await storage.put('temp', key, buffer, 'image/png');
643
+ import { createStorageClient } from 'ugly-app';
644
+ const storage = createStorageClient();
849
645
 
850
- // Move from temp to public bucket
646
+ await storage.put('temp', key, buffer, 'image/png');
851
647
  const publicUrl = await storage.moveToPublic(tempKey, destKey);
852
-
853
- // Get a public URL
854
648
  const url = storage.url('public', destKey);
855
-
856
- // Browser direct upload via presigned URL
857
649
  const { uploadUrl, resultUrl } = await storage.presignedPut('temp', key);
858
650
  ```
859
651
 
860
- Buckets: `'public'` and `'temp'`. Supports Cloudflare R2 (production) or MinIO (dev).
652
+ Client-side, use `socket.uploadFile(file, key)` — it requests a presigned URL via the built-in `uploadUrl` framework request and streams the upload. In dev, uploads go through a same-origin `/_s3` proxy to avoid CORS with local MinIO.
861
653
 
862
- The `StorageClient` interface:
863
-
864
- ```typescript
865
- interface StorageClient {
866
- put(bucket: 'public' | 'temp', key: string, body: Buffer, contentType: string): Promise<string>;
867
- moveToPublic(tempKey: string, destKey: string): Promise<string>;
868
- url(bucket: 'public' | 'temp', key: string): string;
869
- presignedPut(bucket: 'temp', key: string): Promise<{ uploadUrl: string; resultUrl: string }>;
870
- }
871
- ```
872
-
873
- Static build-time assets go in `client/public/`. Never hardcode `/asset/...` paths — use the `buildId` from `shared/Build.ts`.
654
+ `STORAGE_KEY_PREFIX` (env) prefixes all keys — useful for per-environment isolation.
874
655
 
875
656
  ---
876
657
 
877
- ## Billing
878
-
879
- Usage-based billing with per-user limits, global provider limits, pre-paid credits, and threshold alerts.
658
+ ## Workers & cron
880
659
 
881
660
  ```typescript
882
- import { initBillingGateway, getBillingGateway } from 'ugly-app';
661
+ // shared/cron.ts
662
+ import { defineWorkers, z } from 'ugly-app/shared';
883
663
 
884
- const billing = initBillingGateway(db, {
885
- global: { hourlyUsd: 100, thresholdPct: 0.8 },
886
- providers: {
887
- openai: { hourlyUsd: 50, thresholdPct: 0.9 },
664
+ export const cronTasks = defineWorkers({
665
+ dailyCleanup: {
666
+ schedule: '0 3 * * *', // every day at 03:00 UTC
667
+ description: 'Delete completed todos older than 30 days',
668
+ },
669
+ resyncSearch: {
670
+ inputSchema: z.object({ since: z.string().datetime() }),
671
+ description: 'Re-embed search vectors since the given ISO timestamp',
888
672
  },
889
- profitMarginPct: 0.2, // 20% markup on costs
890
- });
891
-
892
- // Charge a user
893
- await billing.charge({
894
- userId: '...',
895
- provider: 'openai',
896
- model: 'gpt-4o',
897
- type: 'textGen', // 'textGen' | 'imageGen' | 'stt' | 'tts' | 'embedding' | 'service'
898
- costUsd: 0.03,
899
- inputTokens: 1000,
900
- outputTokens: 500,
901
673
  });
674
+ ```
902
675
 
903
- // Pre-flight check
904
- const canPay = await billing.canCharge(userId, 0.05);
676
+ ```typescript
677
+ // server/index.ts
678
+ const cronHandlers: WorkerHandlers<typeof cronTasks> = {
679
+ dailyCleanup: async () => { /* runs on schedule */ },
680
+ resyncSearch: async ({ since }) => { /* runs on manual trigger from studio */ },
681
+ };
905
682
 
906
- // Grant credits
907
- await billing.grantCredit(userId, 10.00);
683
+ configurator.setWorkers(cronTasks, cronHandlers);
684
+ ```
908
685
 
909
- // Query spend
910
- const usage = await billing.getSpend(userId, { from, to });
686
+ Each worker can have `inputSchema`, `outputSchema`, `schedule`, `timeout`, `description`. Workers without a schedule are still invocable via `POST /_workers/run` (auth: localhost in dev, `Authorization: Bearer $CRON_SECRET` in prod). Scheduled workers also appear in `/_cron/manifest` for the deploy orchestrator.
911
687
 
912
- // Threshold callbacks
913
- billing.setUserThresholdCallback((userId, period, spend, limit) => { /* alert */ });
914
- billing.setGlobalThresholdCallback((period, spend, limit) => { /* alert */ });
915
- ```
688
+ ---
916
689
 
917
- **Per-user limits** are resolved via `setUserLimitHook()`:
690
+ ## Localization
918
691
 
919
692
  ```typescript
920
- billing.setUserLimitHook(async (userId) => ({
921
- hourlyUsd: 5,
922
- dailyUsd: 50,
923
- weeklyUsd: 200,
924
- thresholds: { hourly: 0.8, daily: 0.9, weekly: 0.95 },
925
- }));
693
+ configurator.setStrings({
694
+ defaultLang: 'en',
695
+ langs: ['en', 'es'],
696
+ criticalKeys: ['app.title', 'nav.home'],
697
+ getTable: (lang) => tables[lang] ?? tables.en,
698
+ });
926
699
  ```
927
700
 
928
- The billing state machine tracks spend across hourly, daily, and weekly windows. Global and per-provider limits are always enforced; user credits act as a fallback when user limits are exceeded but never bypass global limits.
701
+ The framework injects `window.__LANG__`, `window.__STRINGS_VERSION__`, and `window.__CRITICAL_STRINGS__` into SSR HTML. Use `useLocalizer()` / `useStrings()` / `useLang()` / `useChangeLanguage()` on the client.
929
702
 
930
703
  ---
931
704
 
932
705
  ## Experiments
933
706
 
934
- A/B testing with deterministic user bucketing and event tracking.
935
-
936
707
  ```typescript
937
- // shared/experiments.ts
938
708
  import type { Experiment } from 'ugly-app/shared';
939
709
 
940
710
  export const experiments: Experiment[] = [
941
711
  {
942
712
  id: 'new-onboarding',
943
- name: 'New Onboarding Flow',
944
- description: 'Test the redesigned onboarding',
713
+ name: 'New Onboarding',
945
714
  active: true,
946
715
  branches: [
947
- { id: 'control', name: 'Control', weight: 50 },
948
- { id: 'variant', name: 'Variant', weight: 50 },
716
+ { id: 'control', weight: 50 },
717
+ { id: 'variant', weight: 50 },
949
718
  ],
950
- events: ['ONBOARDING_COMPLETE', 'ONBOARDING_SKIP'],
719
+ events: ['ONBOARDING_COMPLETE'],
951
720
  },
952
721
  ];
953
- ```
954
-
955
- **Server-side assignment:**
956
-
957
- ```typescript
958
- import { getExperimentAssignments, getExperimentBranch } from 'ugly-app';
959
-
960
- // Get all active experiment assignments for a user
961
- const branches = getExperimentAssignments(userId, sessionId, experiments);
962
- // => { 'new-onboarding': 'variant' }
963
-
964
- // Get a single experiment branch
965
- const branch = getExperimentBranch(experiment, userId, sessionId);
966
- ```
967
-
968
- Bucketing uses a deterministic hash of `experimentId:userId` (or `sessionId` if no user), so the same user always gets the same branch. Weights control the relative distribution across branches.
969
-
970
- ---
971
-
972
- ## Event logging
973
-
974
- Server-side event capture for analytics, tied to experiments.
975
-
976
- ```typescript
977
- import { eventLogCapture, eventLogServerCapture } from 'ugly-app';
978
-
979
- // Capture with returned eventId
980
- const { eventId } = await eventLogCapture({
981
- eventName: 'BUTTON_CLICK',
982
- sessionId,
983
- userId,
984
- properties: { page: 'home' },
985
- experimentBranches: branches,
986
- }, userId);
987
-
988
- // Fire-and-forget server capture
989
- await eventLogServerCapture('SESSION_START', { source: 'web' }, sessionId, userId, branches);
990
- ```
991
-
992
- **Query functions:**
993
-
994
- | Function | Description |
995
- |----------|-------------|
996
- | `eventLogGetList(input)` | Paginated event list (cursor, date range, filters) |
997
- | `eventLogGetTopUsers(input)` | Top users by event count |
998
- | `eventLogGetTopSessions(input)` | Top sessions by event count |
999
- | `eventLogGetTopEvents(input)` | Top events by frequency |
1000
- | `eventLogGetCounts(input)` | Time-series counts (granularity: `'seconds'` \| `'minutes'` \| `'days'`) |
1001
- | `eventLogGetUniqueUsersCounts(input)` | Unique users per time interval |
1002
- | `eventLogGetUniqueSessionsCounts(input)` | Unique sessions per time interval |
1003
-
1004
- ---
1005
-
1006
- ## Additional server APIs
1007
-
1008
- ### Email
1009
-
1010
- ```typescript
1011
- import { sendEmail, sendTemplateEmail, loadEmailTemplate } from 'ugly-app';
1012
-
1013
- await sendEmail({ to: 'user@example.com', subject: 'Hello', html: '<p>Hi</p>' });
1014
- await sendTemplateEmail('welcome', { name: 'Alice' }, { to: 'user@example.com' });
1015
- ```
1016
-
1017
- ### Push notifications
1018
-
1019
- ```typescript
1020
- import { sendPush, sendFcmPush } from 'ugly-app';
1021
-
1022
- await sendPush(userId, { title: 'New message', body: 'You have a new message' });
1023
- ```
1024
-
1025
- ### NATS (pub/sub)
1026
-
1027
- ```typescript
1028
- import { natsPublish, natsSubscribe, subscribeCollection, subscribeDoc } from 'ugly-app';
1029
-
1030
- natsPublish('my.subject', payload);
1031
- const sub = natsSubscribe('my.subject', (msg) => { /* ... */ });
1032
- sub(); // unsubscribe
1033
- ```
1034
722
 
1035
- ### Redis
1036
-
1037
- ```typescript
1038
- import { getRedisClient, redisGet, redisSet, redisDel, redisPublish, redisSubscribe } from 'ugly-app';
1039
- const redis = getRedisClient();
1040
- ```
1041
-
1042
- ### Worker queues
1043
-
1044
- ```typescript
1045
- import { createWorkerQueue } from 'ugly-app';
1046
-
1047
- const queue = createWorkerQueue({
1048
- streamName: 'JOBS', // NATS stream name (default: 'JOBS')
1049
- concurrency: 10, // max concurrent jobs (default: 10)
1050
- maxRetries: 3, // max retry attempts (default: 3)
1051
- clockServerOnly: true, // only process if IS_CLOCK_SERVER=true (default: true)
1052
- });
1053
-
1054
- queue.registerHandler<MyPayload>('sendEmail', async (job) => {
1055
- job.working(); // extend ack deadline for long-running jobs
1056
- await doWork(job.payload);
1057
- });
1058
-
1059
- await queue.enqueue('sendEmail', { to: 'user@example.com' }, { delay: 5000 });
1060
-
1061
- configurator.setWorkerQueue(queue); // register with the app for lifecycle management
1062
- ```
1063
-
1064
- **Scheduled tasks** — prevent duplicate enqueuing via MongoDB upsert:
1065
-
1066
- ```typescript
1067
- import { enqueueTask } from 'ugly-app';
1068
-
1069
- await enqueueTask(taskDoc, queue, { delay: 60000 });
1070
- ```
1071
-
1072
- ### Billing
1073
-
1074
- ```typescript
1075
- import { initBillingGateway, getBillingGateway } from 'ugly-app';
1076
-
1077
- await initBillingGateway({ /* config */ });
1078
- const billing = getBillingGateway();
1079
- ```
1080
-
1081
- ### Client error capture
1082
-
1083
- ```typescript
1084
- import { captureClientError, initClientLogger } from 'ugly-app/client';
1085
-
1086
- initClientLogger(); // call once at startup — captures unhandled errors
1087
- captureClientError(error); // manually report an error to POST /api/client-error
1088
- ```
1089
-
1090
- ### Feedback
1091
-
1092
- ```typescript
1093
- import { FeedbackButton, setFeedbackContext, clearFeedbackContext } from 'ugly-app/client';
1094
-
1095
- // Render the built-in feedback button (bottom-right, always at [data-id="feedback-button"])
1096
- <FeedbackButton />
1097
-
1098
- // Set contextual data attached to feedback submissions
1099
- setFeedbackContext({ page: 'editor', noteId: '123' });
1100
- clearFeedbackContext();
723
+ configurator.setExperiments(experiments);
1101
724
  ```
1102
725
 
1103
- Server-side, `getFeedbackHandlers(maintainBotUserId)` provides the RPC handlers for submitting and managing feedback. User feedback history is available at `GET /my_feedback` (requires auth cookie, returns markdown).
1104
-
1105
- ### Rate limiting
1106
-
1107
- Rate limiting is configured per-endpoint in the request definition:
1108
-
1109
- ```typescript
1110
- submitFeedback: authReq({
1111
- input: z.object({ ... }),
1112
- output: z.object({ ... }),
1113
- rateLimit: { max: 20, window: 60 }, // 20 requests per 60 seconds
1114
- })
1115
- ```
1116
-
1117
- The framework enforces rate limits automatically before calling the handler.
726
+ Bucketing is deterministic: `hash(experimentId + userId)` (or `sessionId` for unauthenticated users). The framework's `initSession` / `captureEvent` requests automatically tag events with the user's branch assignments.
1118
727
 
1119
728
  ---
1120
729
 
@@ -1122,59 +731,19 @@ The framework enforces rate limits automatically before calling the handler.
1122
731
 
1123
732
  | Endpoint | Description |
1124
733
  |----------|-------------|
1125
- | `GET /health` | Health check — returns `{ status: 'ok', timestamp }` |
1126
- | `POST /auth/verify` | Exchange OAuth code for a session cookie |
1127
- | `POST /auth/logout` | Clear the auth cookie |
1128
- | `GET /auth/token` | Refresh and return the current token |
1129
- | `GET /auth/url` | Get the OAuth popup URL |
1130
- | `POST /api/:name` | RPC endpoint dispatches any registered request handler |
1131
- | `POST /ai/request` | AI proxy forwards to ugly.bot (requires auth) |
1132
- | `POST /api/client-error` | Client-side error capture |
1133
- | `GET /my_feedback` | User feedback history (markdown, requires auth) |
1134
-
1135
- ---
1136
-
1137
- ## Migrations
1138
-
1139
- Never change a collection field type without writing a migration:
1140
-
1141
- ```typescript
1142
- // server/migrations/001-add-bio.ts
1143
- export const name = '001-add-bio';
1144
- export async function up(db: Db) {
1145
- await db.collection('user').updateMany({}, { $set: { bio: '' } });
1146
- }
1147
- ```
1148
-
1149
- Run with `npm run db:migrate`. Use `npm run db:migrate -- --status` to preview pending migrations.
1150
-
1151
- ---
1152
-
1153
- ## CLI reference
1154
-
1155
- | Command | Description |
1156
- |---------|-------------|
1157
- | `ugly-app init <name>` | Scaffold a new project |
1158
- | `ugly-app upgrade` | Upgrade framework config files to the latest version |
1159
- | `ugly-app configure` | Generate `.uglyapp` config |
1160
- | `ugly-app dev` | Start all dev services (Docker, server, Vite, tsc, eslint) |
1161
- | `ugly-app build` | Production build |
1162
- | `ugly-app login` | Login to ugly.bot |
1163
- | `ugly-app db:migrate` | Run pending migrations (`--status` to preview) |
1164
- | `ugly-app deploy:single` | Deploy to a single server |
1165
- | `ugly-app deploy:multi` | Deploy to multiple servers |
1166
- | `ugly-app publish:assets` | Push static assets to CDN (`--dry-run` to preview) |
1167
- | `ugly-app purge:assets` | Clean old builds (`--keep <n>`, `--dry-run`) |
1168
- | `ugly-app test:e2e` | Run Playwright end-to-end tests (`--headed` for browser) |
1169
- | `ugly-app logs:local` | Query local dev logs |
1170
- | `ugly-app logs:server` | Query server logs from MongoDB |
1171
- | `ugly-app error:dev` / `error:prod` | Query error logs (dev tunnel / production) |
1172
- | `ugly-app perf:dev` / `perf:prod` | Query performance metrics (dev tunnel / production) |
1173
- | `ugly-app feedback:dev` / `feedback:prod` | Query user feedback (dev tunnel / production) |
1174
- | `ugly-app auth:create-account` | Create an account in the database |
1175
- | `ugly-app auth:create-token` | Generate a JWT for a userId |
1176
- | `ugly-app textGen [prompt]` | Generate text via AI (`--model`, `--system-prompt`, `--max-tokens`, `--json`) |
1177
- | `ugly-app imageGen [prompt]` | Generate an image via AI (`--model`, `--output <path>`) |
734
+ | `GET /health` | Health check — returns `{ status, timestamp, lastRequestAt }`. |
735
+ | `POST /api/:name` | Dispatch any registered request handler over HTTP. |
736
+ | `POST /auth/verify` | Exchange OAuth code for session cookie. |
737
+ | `POST /auth/logout` | Clear the auth cookie. |
738
+ | `GET /auth/token` | Refresh and return the current token. |
739
+ | `GET /auth/url` | Get the OAuth popup URL. |
740
+ | `GET /_workers/manifest` | Worker definitions (used by ugly-studio). |
741
+ | `POST /_workers/run` | Synchronously invoke a worker handler. |
742
+ | `GET /_workers/runs` | Recent in-memory worker runs (last 200). |
743
+ | `GET /_cron/manifest` | Cron tasks for the deploy orchestrator. |
744
+ | `POST /api/_cron/:taskName` | Trigger a cron task (auth via `CRON_SECRET`). |
745
+ | `POST /internal/email-callback` | Inbound email gateway (auth via `INTERNAL_EMAIL_SECRET`). |
746
+ | `PUT /_s3/*` | Dev-only S3 upload proxy (avoids CORS with MinIO). |
1178
747
 
1179
748
  ---
1180
749
 
@@ -1182,11 +751,17 @@ Run with `npm run db:migrate`. Use `npm run db:migrate -- --status` to preview p
1182
751
 
1183
752
  | Import path | Description |
1184
753
  |-------------|-------------|
1185
- | `ugly-app` | Server APIs (createApp, DB, auth, AI, email, storage, billing, worker queues, etc.) |
1186
- | `ugly-app/shared` | Shared types and utilities (defineRequests, defineCollections, definePage, experiments, Zod, etc.) |
1187
- | `ugly-app/client` | Client APIs (createSocket, createRouter, AppProvider, components, animations, audio, etc.) |
1188
- | `ugly-app/playwright` | Playwright test utilities |
1189
- | `ugly-app/webrtc` | WebRTC utilities |
754
+ | `ugly-app` | Server: `createApp`, `TypedDB`, auth, AI clients, NATS, storage, email, push, workers. |
755
+ | `ugly-app/shared` | Cross-tier: `defineRequests`, `defineCollections`, `definePage`, `defineWorkers`, Zod, experiments, time constants. |
756
+ | `ugly-app/client` | React: `bootstrapApp`, `createRouter`, `lazyPage`, `AppProvider`, components, animations, audio, AI helpers. |
757
+ | `ugly-app/conversation/{shared,server,client}` | AI chat sessions with persisted history. |
758
+ | `ugly-app/collab/{server,client}` | Yjs-based collaborative editing. |
759
+ | `ugly-app/markdown/{shared,client}` | Markdown rendering + editor. |
760
+ | `ugly-app/webrtc`, `ugly-app/webrtc/server` | WebRTC video rooms. |
761
+ | `ugly-app/three/{server,client}` | Three.js scene helpers. |
762
+ | `ugly-app/worker` | Worker queue runtime. |
763
+ | `ugly-app/playwright` | Test utilities. |
764
+ | `ugly-app/vite`, `ugly-app/eslint` | Build-tool plugins. |
1190
765
 
1191
766
  ---
1192
767
 
@@ -1194,75 +769,67 @@ Run with `npm run db:migrate`. Use `npm run db:migrate -- --status` to preview p
1194
769
 
1195
770
  | Variable | Description |
1196
771
  |----------|-------------|
1197
- | `JWT_SECRET` | **Required** signs auth tokens |
1198
- | `JWT_EXPIRY_SECONDS` | Token lifetime (optional, default: 2592000 = 30 days) |
1199
- | `MONGODB_URI` | MongoDB connection string |
1200
- | `PORT` | Server port (default: 3000) |
1201
- | `NODE_ENV` | `development` or `production` |
1202
- | `UGLY_BOT_TOKEN` | App token for AI proxy (`/ai/request`) |
1203
- | `REDIS_URL` | Redis (optional, in-memory fallback for dev) |
1204
- | `NATS_URL` | NATS server URL |
1205
- | `CLOCK_ENABLED` | Set to `true` to enable `setOnMinuteTick`/`setOnHourlyTick` handlers |
1206
- | `IS_CLOCK_SERVER` | Set to `true` on the instance that should process delayed worker queue jobs |
1207
- | `STORAGE_ACCOUNT_ID` | Cloudflare R2 account ID |
1208
- | `STORAGE_ACCESS_KEY_ID` | R2 access key |
1209
- | `STORAGE_SECRET_ACCESS_KEY` | R2 secret key |
1210
- | `STORAGE_PUBLIC_BUCKET` | R2 public bucket name |
1211
- | `STORAGE_TEMP_BUCKET` | R2 temp bucket name |
1212
- | `STORAGE_PUBLIC_URL` | Base URL for public assets |
1213
- | `TOGETHER_API_KEY` | Together AI key |
1214
- | `ANTHROPIC_API_KEY` | Anthropic Claude key |
1215
- | `OPENAI_API_KEY` | OpenAI key |
1216
- | `GOOGLE_API_KEY` | Google Gemini key |
1217
- | `GROQ_API_KEY` | Groq key |
1218
- | `KIE_API_KEY` | Kie.ai key |
1219
- | `KIE_BASE_URL` | Kie.ai base URL override (optional) |
1220
- | `DEEPGRAM_API_KEY` | Deepgram STT key |
1221
- | `AZURE_TTS_KEY` | Azure TTS key |
1222
- | `AZURE_TTS_REGION` | Azure TTS region |
1223
- | `KAGI_API_KEY` | Kagi web search key |
1224
- | `MAILGUN_API_KEY` | Mailgun key |
1225
- | `MAILGUN_DOMAIN` | Mailgun sending domain |
1226
- | `MAILGUN_FROM` | Default from address |
1227
-
1228
- Client-side variables must be prefixed with `VITE_`.
772
+ | `PORT` | Server port (templates default to 4321). |
773
+ | `NODE_ENV` | `development` or `production`. |
774
+ | `UGLY_BOT_TOKEN` | App token for the ugly.bot platform — required for AI, logs, billing. |
775
+ | `UGLY_BOT_URL` | Override the platform base URL (default `https://ugly.bot`). |
776
+ | `DATA_PROXY_URL` | WebSocket URL for the data proxy (default `ws://localhost:4200`). |
777
+ | `DATA_PROXY_TOKEN` | Auth token for the data proxy. |
778
+ | `STORAGE_KEY_PREFIX` | Prefix all storage keys (per-env isolation). |
779
+ | `MINIO_ENDPOINT` | Dev-only S3 endpoint for the upload proxy. |
780
+ | `NATS_PREFIX` / `COMPOSE_PROJECT_NAME` | NATS subject prefix for per-env isolation. |
781
+ | `CLOCK_ENABLED` | `true` to enable `setOnMinuteTick` / `setOnHourlyTick`. |
782
+ | `CRON_SECRET` | Bearer secret for `POST /api/_cron/:taskName` and prod `POST /_workers/run`. |
783
+ | `MAINTAIN_BOT_USER_ID` | User id allowed to access admin-only handlers. |
784
+ | `INTERNAL_EMAIL_SECRET` | Shared secret for `/internal/email-callback`. |
785
+ | `JWT_SECRET` | Required when using `getRequestUser()` for the per-project session cookie. |
786
+ | `APP_DOMAIN` | App domain; combined with `NATS_PREFIX` for `getRequestUser()` validation. |
787
+ | `LOG_CAPTURE_URL` | Studio override for client log capture (empty → ugly.bot default). |
788
+ | `UGLY_APP_HMR` | Set to `false` to disable Vite HMR in dev. |
789
+ | `SCHEMA_CHECK_SKIP` | `true` to start despite schema drift (unsafe). |
790
+
791
+ Browser-visible variables must be prefixed `VITE_` and consumed via `import.meta.env.VITE_*`.
1229
792
 
1230
793
  ---
1231
794
 
1232
- ## Shared utilities
795
+ ## CLI
1233
796
 
1234
- `ugly-app/shared` exports common helpers used by both server and client:
797
+ | Command | Description |
798
+ |---------|-------------|
799
+ | `ugly-app init <name>` | Scaffold a new project. |
800
+ | `ugly-app upgrade` | Upgrade framework config files to the latest version. |
801
+ | `ugly-app configure` | Generate/update `.uglyapp` config. |
802
+ | `ugly-app login` | Authenticate with ugly.bot. |
803
+ | `ugly-app url` | Print the local dev server URL. |
804
+ | `ugly-app deploy` | Build + push to production infrastructure. |
805
+ | `ugly-app prod --buildId <id>` | Promote a build to prod. |
806
+ | `ugly-app versions` | List deployed versions. |
807
+ | `ugly-app versions:prune` | Clean up non-prod versions. |
808
+ | `ugly-app infra:destroy` | Tear down all project infra. |
809
+ | `ugly-app textGen [prompt]` | Generate text via AI (`--model`, `--system-prompt`, `--max-tokens`, `--json`). |
810
+ | `ugly-app imageGen [prompt]` | Generate an image (`--model`, `--output <path>`). |
811
+ | `ugly-app error:dev` / `error:prod` | Query error logs (your tunnel / production). |
812
+ | `ugly-app perf:dev` / `perf:prod` | Query performance metrics. |
813
+ | `ugly-app feedback:dev` / `feedback:prod` | Query user feedback. |
814
+ | `ugly-app feedback:submit` / `feedback:resolve` | Manage feedback (run with `--help` for flags). |
815
+
816
+ Inside a scaffolded project, the same commands are available via `npm run …` scripts — see `templates/CLAUDE.md` for the full list.
1235
817
 
1236
- ```typescript
1237
- import {
1238
- isDefined,
1239
- compact,
1240
- debounce,
1241
- formatDate,
1242
- formatRelativeTime,
1243
- oneSecond,
1244
- oneMinute,
1245
- oneHour,
1246
- oneDay,
1247
- oneWeek,
1248
- } from 'ugly-app/shared';
1249
-
1250
- isDefined(value); // type guard — true if not null/undefined
1251
- compact([1, null, 2, undefined]); // [1, 2] — filters out null/undefined
1252
- const debouncedFn = debounce(fn, 300);
1253
- formatDate(new Date()); // locale-formatted date string
1254
- formatRelativeTime(new Date()); // "2 hours ago", "just now", etc.
1255
-
1256
- // Time constants (milliseconds)
1257
- oneSecond; // 1000
1258
- oneMinute; // 60_000
1259
- oneHour; // 3_600_000
1260
- oneDay; // 86_400_000
1261
- oneWeek; // 604_800_000
1262
- ```
818
+ ---
819
+
820
+ ## Migrations
821
+
822
+ Schema changes must be deliberate:
823
+
824
+ 1. Update the Zod schema in `shared/collections.ts`.
825
+ 2. Run `npm run db:schema-gen` — produces a migration file with compile-blocking `REPLACE_ME` placeholders for any non-trivial change.
826
+ 3. Replace every `REPLACE_ME` with the correct migration logic.
827
+ 4. Run `npm run db:migrate`.
828
+
829
+ The framework refuses to start when drift is detected (set `SCHEMA_CHECK_SKIP=true` only as a temporary escape hatch).
1263
830
 
1264
831
  ---
1265
832
 
1266
833
  ## Tech stack
1267
834
 
1268
- Node.js · TypeScript · Express · React 19 · Vite · Tailwind CSS · MongoDB · NATS · Redis · Cloudflare R2 · Zod · JWT (jose) · ugly.bot OAuth
835
+ Node.js · TypeScript · Express · React 19 · Vite · PostgreSQL (JSONB) · Qdrant · NATS · S3-compatible storage · Zod · JWT (jose) · ugly.bot platform