@athenox/talos
Version:
Talos: a backend you describe instead of write. Declare a schema, write the rules in plain language, map URLs to sentences; a small built-in model turns them into query plans.
140 lines (110 loc) • 5.34 kB
Markdown
# Getting started
## 1. Describe the app
One `defineApp` holds everything the backend knows: the schema, the rules in
prose, and optionally the database.
```ts
// server/utils/talos.ts
import { defineApp, hashPassword } from '@athenox/talos'
export const app = defineApp({
prompt: `
show the recipes, the cooks and the comments.
a member may add a recipe, and change or delete their own.
a member may comment.
an admin may do anything.`,
schema: {
tables: [
{
as: 'recipes', table: 'recipes', says: ['dishes', 'recettes'],
columns: { id: 'id', title: 'title', body: 'body', course: 'course', minutes: 'minutes', author: 'author_id', date: 'created_at' },
enums: { course: ['main', 'starter', 'dessert'] },
types: { minutes: 'number' },
defaults: { insert: { created_at: () => new Date().toISOString() } },
relations: { author: { table: 'users', on: 'author_id', columns: { id: 'id', name: 'name' } } },
rules: { title: { required: 'insert', trim: true, min: 3, max: 120, message: 'A title of 3 to 120 characters' } },
roles: { write: ['member', 'admin'] },
},
{
as: 'users', table: 'users',
columns: { id: 'id', name: 'name', email: 'email', password: 'password', role: 'role' },
enums: { role: ['member', 'admin'] },
hash: { password: hashPassword },
hidden: ['password', 'email'],
roles: { write: ['admin'] },
},
],
},
seed: {
users: [{ name: 'Ada', email: 'ada@example.com', password: 'change-me-please', role: 'admin' }],
},
})
```
**The prompt** is read by the model with every request. Write it the way you
would explain the app to a colleague: what can be shown, who may do what. It
guides the plan; it does not grant anything — permissions are the schema's
`roles` and your route guards.
**The schema** is described in [schema.md](schema.md). Logical column names
(the keys of `columns`) are what the model reads; physical names are what the
database has. Keep them equal and in English when you can (`title`, not
`titre`): rows come back with physical names, and the model knows English
schema words best. Put the words people actually use in `says` and `columnSays`.
## 2. Pick a database
**None.** Leave `store` out: Talos opens `.talos/data.db` (or `TALOS_DB`), creates
every table and column the schema names, adds new ones on the next start and
never drops anything. `id` is the integer primary key, a column with declared
values defaults to the first one (`role: ['member', 'admin']` → `member`), and
`seed` rows go into tables that are still empty — hashed and defaulted as the
schema says. `await app.ready` resolves once the file is open.
**Yours.** Pass a store:
```ts
import { DatabaseSync } from 'node:sqlite'
import mysql from 'mysql2/promise'
import { sqliteStore, mysqlStore, syncSchema } from '@athenox/talos'
defineApp({ ..., store: sqliteStore(new DatabaseSync('app.db')) }) // or better-sqlite3
defineApp({ ..., store: mysqlStore(mysql.createPool(process.env.DATABASE_URL)) })
```
With a Drizzle schema, `fromDrizzle(schema)` gives the tables and
`syncSchema(store, schema)` creates what is missing (see [reference.md](reference.md)).
## 3. Serve it
### Nuxt / Nitro
```ts
// server/api/[...].ts
import { defineRoutes } from '@athenox/talos'
export default defineRoutes(app, {
'GET /api/recipes': { say: 'show the recipes', orderBy: '-date', limit: 20, total: true },
'GET /api/recipes/:id': { say: 'show the recipe {id}', one: true, notFound: 'No such recipe' },
'POST /api/recipes': { say: 'add a recipe', guard: member, ctx: asMember, stamp: (_, me) => ({ author_id: me.id }) },
'PUT /api/recipes/:id': { say: 'change the recipe {id}', guard: member, ctx: asMember, where: own },
'DELETE /api/recipes/:id': { say: 'delete the recipe {id}', guard: member, ctx: asMember, where: own },
})
```
`member`, `asMember` and `own` come from [accounts](accounts.md):
```ts
export const member = users.guard() // the signed-in account, or 401
export const asMember = (me) => ({ userId: me.id, role: me.role }) // what Talos checks roles against
export const own = (_, me) => (me.role === 'admin' ? {} : { author_id: me.id }) // rows that are theirs
```
In `nuxt.config.ts`, keep Talos out of the bundle (it loads its model from its
own folder):
```ts
nitro: { externals: { external: ['@athenox/talos', 'onnxruntime-node', 'sharp'] } }
```
### Plain h3 / Node
```ts
import { createServer } from 'node:http'
import { createApp, toNodeListener } from 'h3'
const server = createApp()
server.use(defineRoutes(app, { /* the same table */ }))
createServer(toNodeListener(server)).listen(3000)
```
## 4. Check what it understood
Every answer carries the plan in its `x-talos-plan` header and the model call in
`x-talos-call`:
```
x-talos-plan: delete the recipe 12 => DELETE recipes FILTER id VAL 12 (compiled)
```
`(compiled)` means the plan was replayed without the model. The first call of a
route shows the model's plan — read it once when you add a route.
## Next
- [Routes](routes.md): templates, every option, free-text requests
- [Files and images](files.md): uploads
- [Telemetry and feedback](telemetry.md)