UNPKG

@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.

570 lines (569 loc) • 23.1 kB
import { type RecommendOptions } from './recommend.ts'; import { type FindOptions } from './search.ts'; import { type ImageOptions } from './images.ts'; import { Telemetry, type TelemetryOptions } from './telemetry.ts'; /** * Teach the runtime the words an app's own vocabulary is spoken with. * * Declaring a value *replaces* its forms rather than adding to them. Merging * looked harmless and was not: editing this list during development left the * process holding the union of every version it had seen, which shifts every * pointer position in the window — reading broke and the cause was invisible. * The app states the full list for a value; what it states is what is used. */ export declare function addSurfaceForms(forms: Record<string, string[]>): void; /** * A text as the model's encoder reads it: the mean of its token states, unit * length. Two texts about the same thing land close together — the basis of * `recommend`. Texts longer than the window are cut. */ export declare function embed(text: string): Promise<Float32Array>; export declare const normalize: (s: unknown) => string; export type Rule = { /** `'insert'`: required when a row is created, optional in an edit */ required?: boolean | 'insert'; min?: number; max?: number; email?: boolean; pattern?: RegExp; /** trim the value before checking and writing it */ trim?: boolean; /** no other row may hold the same value; a string is that failure's own message */ unique?: boolean | string; /** with `unique`: the value only has to be unique together with these logical columns (one grant per subscriber and article) */ uniqueWith?: string[]; /** shown to the caller when the value breaks the rule */ message?: string; }; export type Table = { /** the word the model sees; may differ from the physical table */ as: string; table: string; /** optional read-side view, when a useful row needs a join */ view?: string; columns: Record<string, string>; enums?: Record<string, string[]>; /** * Boolean columns, as the two words people use for them: `visible: * ['masquee', 'visible']`. They are a two-value domain to the model and 0 or 1 * to the database. */ flags?: Record<string, [string, string]>; /** * Rows this table points at, read along with it: `author: { table: 'users', * on: 'author_id', columns: { id: 'id', name: 'name' } }` gives each row an * `author` object. Reads only; writes still touch this table alone. */ relations?: Record<string, { table: string; on: string; key?: string; columns: Record<string, string>; }>; /** logical columns a route's free-text search looks in */ searchable?: string[]; /** physical columns that hold JSON, parsed on the way out */ json?: string[]; /** * How physical columns read back, when the driver hands out something else: * `{ is_published: 'boolean', event_date: 'date', meta: 'json', price: 'number' }`. * A date is read from ISO or SQL text, or from seconds or milliseconds. */ types?: Record<string, 'boolean' | 'date' | 'json' | 'number'>; /** * Per physical column, how a driver value reads back and how a value is * written — an ORM's own column conversions (see `fromDrizzle`). They win * over `types`. */ read?: Record<string, (v: unknown) => unknown>; write?: Record<string, (v: unknown) => unknown>; /** * Slug columns and the logical column each is made from: `{ slug: 'title' }`, * or `{ slug: { from: 'title', keep: true } }` for a slug set once, at creation. */ slugs?: Record<string, string | { from: string; keep?: boolean; }>; /** values the schema computes when a write leaves the column out, by physical column */ defaults?: { insert?: Record<string, () => unknown>; update?: Record<string, () => unknown>; }; /** * A column the model fills by meaning when a write leaves it empty: the * value whose description the row's text is closest to (a category from the * title and content). `{ category: { from: ['title', 'content'], labels: { * sport: 'football, match, stade', culture: 'concert, festival, expo' } } }`, * by physical column. Needs the embedder; skipped without it. */ infer?: Record<string, { from: string[]; labels: Record<string, string>; min?: number; }>; /** physical columns whose written value is replaced by its hash (a password), after the rules */ hash?: Record<string, (v: string) => string | Promise<string>>; /** * Physical columns that hold an uploaded file's path. A multipart request's * file of the same field name is checked, optionally transformed (resized), * stored under `dir` with a random name, and the column set to `url` + name; * the file it replaces, or that belonged to a deleted row, is removed. */ files?: Record<string, { dir: string; url?: string; accept?: RegExp; maxBytes?: number; ext?: string; message?: string; /** a row must be created with a file: this is the message when none is sent */ required?: string; /** an image is processed before it is stored: size, format, quality, metadata stripped */ image?: ImageOptions; /** more sizes of the same image, each stored in its own column: `{ thumb_path: { width: 300 } }` */ variants?: Record<string, ImageOptions>; /** columns that receive the stored file's facts: `{ width: 'width', height: 'height', size: 'size', type: 'mime_type', name: 'original_name' }` */ record?: Partial<Record<'width' | 'height' | 'size' | 'type' | 'name', string>>; transform?: (data: Buffer, file: { filename?: string; type?: string; }) => Buffer | Promise<Buffer>; }>; /** fields added to each row read, from the row: `{ gravatar: r => hash(r.email) }` */ computed?: Record<string, (row: Record<string, any>) => unknown>; /** physical columns never returned (a password hash); dotted for a relation's (`author.email`) */ hidden?: string[]; /** * What a written value must look like, by logical column. Checked on every * INSERT and UPDATE, whoever wrote the value — the model or a form. A failed * check is a 400 listing every message, as a form wants them. */ rules?: Record<string, Rule>; /** * Declared functions to call after a write on this table succeeds, with the * written values and the new id: `{ insert: ['notifyModerators'] }`. */ after?: { insert?: string[]; update?: string[]; delete?: string[]; }; /** * Rows elsewhere that point at this table, for a delete: `cascade` removes * them first, `restrict` refuses the delete while any exist (with `message`). * For schemas that do not declare it as a foreign key. */ children?: { table: string; on: string; cascade?: boolean; message?: string; }[]; /** * Rows elsewhere read along with each row, as a list: `attachments: { table: * 'article_attachments', on: 'article_id', columns: {...}, orderBy: 'position' }`. */ lists?: Record<string, { table: string; on: string; columns: Record<string, string>; orderBy?: string; limit?: number; /** conditions on the listed rows, by physical column (`{ status: 'published' }`, comparisons as in `where`) */ where?: Record<string, unknown>; /** rows each listed row points at, nested as objects (a purchase's article) */ relations?: Record<string, { table: string; on: string; key?: string; columns: Record<string, string>; }>; }>; /** * Many-to-many through a join table, read as a list and written from a list * of ids in the body (`field`, default `<name>Ids`): `categories: { through: * 'article_categories', from: 'article_id', to: 'category_id', table: * 'categories', columns: { id: 'id', name: 'name' } }`. */ many?: Record<string, { through: string; from: string; to: string; table: string; key?: string; columns: Record<string, string>; field?: string; }>; /** * A count of rows elsewhere, per row: `views: { table: 'article_views', on: * 'article_id' }`. Returned with each row, and usable in `orderBy` and `where`. */ tallies?: Record<string, { table: string; on: string; where?: Record<string, unknown>; }>; /** rows belong to one caller; the runtime pins that, never the model */ ownerColumn?: string; ownerFrom?: 'userId' | 'sid'; /** columns a caller may write; anything else in a plan is ignored */ writable?: string[]; /** * How people ask for this table, besides its name: "abonnés" for `members`, * "commandes" for `orders`. Matched literally against the request, which is * what lets a schema use a word the weights know while readers use their own. */ says?: string[]; /** * How people name a column, besides its own name: `section: ['rubrique']`. * * These are structural words, not values. Asked for "les articles de la * rubrique sport" the model filtered the title on `rubrique`; declaring it * here is what lets the runtime see that the caller was naming a column and * not searching for the word. */ columnSays?: Record<string, string[]>; /** * Who may read and who may write this table. * * Authorisation is NOT the model's job. Across six training runs its refusal of * out-of-scope requests reached 1.000 while its denial of unauthorised ones * stalled at 0.607 — and a permission check that is right two times in three is * not a permission check. The model says what was asked; this says who may. */ roles?: { read?: string[]; write?: string[]; /** who may INSERT and who may DELETE, when that differs from write */ create?: string[]; remove?: string[]; /** * Who may write a particular column. A journalist files a draft; only an * editor moves it to published — which is one column, not one table, so * table-level write cannot say it. */ columns?: Record<string, string[]>; }; }; export type Fn = { name: string; /** the argument names the prose may use, in the order a sentence tends to give them */ params: string[]; /** those that must be present; the call is refused rather than run without them */ required?: string[]; /** a parameter the runtime fills from the session, never from the sentence */ fromContext?: Record<string, keyof Ctx | string>; /** * How people ask for this, besides its name: "rebuild the search index", * "envoie la newsletter". Matched literally against the request, so an app can * name its actions freely and still be reached by ordinary sentences. */ says?: string[]; roles?: string[]; }; export type Schema = { tables: Table[]; functions?: Fn[]; }; type Maybe<T> = T | Promise<T>; export type Store = { all(sql: string, ...p: unknown[]): Maybe<Record<string, any>[]>; run(sql: string, ...p: unknown[]): Maybe<{ changes: number | bigint; lastInsertRowid: number | bigint; }>; /** free text -> row id, for an item a sentence names */ lookup(table: string, text: string): Maybe<number | null>; /** how identifiers are quoted and rows inserted; SQLite when absent */ dialect?: 'sqlite' | 'mysql'; }; /** * Warn about schema words the model has never seen. * * An out-of-vocabulary word in the input window is not ignored by a pointer * decoder — it is a position, so it becomes something to point at, and it * competes with the right answer. Naming a column `headline` instead of `title` * was enough to turn every filter on this newspaper into * `SELECT articles FILTER headline VAL <the first noun of the sentence>`. * * Values are a different matter: a row's title or an invented section is * *meant* to be unknown, and the pointer mechanism exists precisely so those * work. It is the schema's own words — tables, columns, enum values — that * should be words the weights know. */ export declare function checkVocabulary(schema: Schema, warn?: (...data: any[]) => void): string[]; /** * What the model is told the caller is. * * An app may name its roles anything — reader, journalist, editor — but those * words are not in the model's vocabulary, and an unknown word in the context * does not merely get ignored: it becomes a pointer target. With `role = editor` * in the window, "montre moi tous les articles" came back as * `SELECT articles FILTER title VAL editor`. Every skill collapsed, not just the * ones about permission. * * So the model is told one of the three words it was trained on, derived from * what the schema actually lets this role do. It costs nothing: the model does * not decide permission, `authorise` does, and that keeps the real role. */ export declare function modelRole(schema: Schema, role: string | undefined, signedIn: boolean): string; /** * The permission gate: the only thing that decides access, and the reason no app * in this repo has an auth.ts. Pure and exported so it can be tested against a * plan directly — testing it through a sentence would test the model instead, * and the model is not what enforces this. * * Returns the refusal message, or null when the role may proceed. */ export declare function authorise(plan: Plan, schema: Schema, role: string): string | null; export type Ctx = Record<string, any> & { userId?: number; role?: string; }; /** * How a route wants its rows back. Presentation, not intent: the order a list * is shown in and how much of it fits a page are the route's to say, never the * model's to guess. Only a SELECT reads them. */ export type Shape = { /** a column, or several; `-date` is descending */ orderBy?: string | string[]; desc?: boolean; limit?: number; offset?: number; /** 1-based page of `limit` rows, instead of an offset */ page?: number; /** also count every matching row, for a pager */ total?: boolean; /** one row expected: returned alone, and a 404 when there is none */ one?: boolean; /** free text matched against the table's `searchable` columns */ search?: string; /** a DELETE also returns the rows it removed; an INSERT returns the row it made (`result.row`) */ returning?: boolean; /** * Rank the rows the plan chose for one reader — what they read, what readers * like them read, popularity, recency — and keep the best `limit` (see * recommend.ts). Columns are physical. */ recommend?: RecommendOptions; /** * Order the rows the plan chose by how close their text is to `to` (a text, * or a row's text when `to` is `{ id }`): "à lire aussi", near-duplicates. */ similar?: { to: string | { id: unknown; }; text: string[]; limit?: number; min?: number; }; /** full-text search over the rows the plan chose, by weighted fields, prefixes, typos and meaning (see search.ts) */ find?: FindOptions; /** which of the table's `lists` a read loads (all by default; `false` for none) */ lists?: string[] | false; /** a route's own image processing for a file column, over the table's (`{ filename: { width: 400, height: 400, fit: 'cover' } }`) */ images?: Record<string, ImageOptions>; /** a multipart request's files, for the table's `files` columns */ uploads?: { name?: string; filename?: string; type?: string; data: Buffer; }[]; /** * More counts for the same read, each with its own conditions (same syntax as * `where`, applied instead of the request's): `{ pending: { status: 'pending' } }` * for a badge next to a filtered list. */ counts?: Record<string, Record<string, unknown>>; /** sums over a column, each with its own conditions: `{ revenue: { column: 'amount', where: {...} } }` */ sums?: Record<string, { column: string; where?: Record<string, unknown>; }>; /** * An INSERT of several rows at once, by column name, each written or — with * `upsert` — replacing the row with the same unique key: key/value settings. */ rows?: Record<string, unknown>[]; /** insert or update in place on a unique column (`'email'`; `true` means the key); the result says `action: 'inserted' | 'updated'` */ upsert?: boolean | string; /** bulk rows: a row colliding with a unique key is skipped and counted, not an error */ ignore?: boolean; /** a DELETE that removes duplicates: all but the first row (lowest id) of each value of `by` */ dedupe?: { by: string; }; /** * Runs after the rules pass and before the write, and may add columns: the * place to store an upload, so an invalid form leaves no orphan file. */ before?: (row: Record<string, unknown>) => Record<string, unknown> | void | Promise<Record<string, unknown> | void>; /** * The operations this route can mean. A PUT route is an update whatever the * sentence sounds like: "modifie la categorie 2 : nom Sport, masquee" came * back as a SELECT, and a read answering a write with 200 tells the caller * the change was made. Refusal stays possible. */ ops?: string[]; /** * The row, when the URL already names it: `/api/categories/1`. It replaces any * condition the model wrote on those columns — "change la categorie 1 ... et * l ordre 2" once updated category 2. */ where?: Record<string, unknown>; /** * Values the request already holds by name, as a form body does. Written to * the columns they name (logical or physical, camelCase or not) if the plan * writes that table and the column is writable; they win over whatever the * model copied out of the sentence. */ values?: Record<string, unknown>; /** * Physical columns the route itself writes on an INSERT or UPDATE — who * moderated and when. Facts about the request, not things the caller says. */ stamp?: Record<string, unknown>; }; export type Actions = Record<string, (a: { args: Record<string, string>; ctx: Ctx; plan: Plan; }) => unknown | Promise<unknown>>; /** * The exact token window the model reads for a request — for diagnosis, e.g. * comparing a real app's window with the ones training generates. */ export declare function windowFor(schema: Schema, prompt: string, ctx: Ctx, input: string): string[]; export type Clause = { column: string; value: string[]; /** true when the value was pointed at inside the caller's own sentence */ said?: boolean; }; export type Plan = { op: string; target: string | null; filters: Clause[]; sets: Clause[]; tokens: string[]; /** clauses the runtime moved to the column that declares their value */ repaired?: string[]; /** the model call that made this plan — what telemetry and feedback refer to */ callId?: string; }; /** * The plan as it will actually run, which is not always what the model wrote: * a clause the runtime moved to the column that declares its value shows here in * its repaired form. `plan.tokens` keeps the raw generation, and `plan.repaired` * says what moved, so both are visible rather than one quietly standing in for * the other. */ export declare const planText: (plan: Plan) => string; /** `slugify('Économie locale')` → `economie-locale` */ export declare const slugify: (s: string) => string; export declare function defineApp(opts: { schema: Schema; prompt: string; /** Your database. Left out, Talos keeps its own (see local.ts): a SQLite file made from the schema. */ store?: Store; /** Rows for Talos's own database, put in tables that are still empty (logical or physical names). */ seed?: Record<string, Record<string, unknown>[]>; actions?: Actions; /** Set false to stop the vocabulary warning, once you have read it. */ checkVocab?: boolean; /** What this instance reports (see telemetry.ts): on by default, `false` for nothing, or part by part. */ telemetry?: TelemetryOptions | false; }): { run: (input: string, ctx?: Ctx, shape?: Shape) => Promise<{ callId: string; plan: Plan; ms: number; status: any; result: { error: any; errors: any; data: any; }; } | { callId: string; plan: Plan; ms: number; status: number; result: any; }>; decide: (input: string, ctx: Ctx, ops?: string[], template?: string) => Promise<{ plan: Plan; ms: number; refuseP: number; callId: `${string}-${string}-${string}-${string}-${string}`; }>; execute: (plan: Plan, ms: number, input: string, ctx: Ctx, shape: Shape, cached?: boolean) => Promise<{ callId: string; plan: Plan; ms: number; status: any; result: { error: any; errors: any; data: any; }; } | { callId: string; plan: Plan; ms: number; status: number; result: any; }>; route: (template: string, base?: Shape) => (p: Record<string, unknown>, ctx?: Ctx, shape?: Shape) => Promise<{ callId: string; plan: Plan; ms: number; status: any; result: { error: any; errors: any; data: any; }; sentence: string; cached: boolean; } | { callId: string; plan: Plan; ms: number; status: number; result: any; sentence: string; cached: boolean; }>; prompt: string; schema: Schema; /** The database Talos reads and writes: yours, or its own. */ store: Store; /** Resolves once Talos's own database is open, with its file; at once with your store. */ ready: Promise<{ file: string | undefined; }>; /** This instance's id: what someone quotes to be helped with it. */ instanceId: string; /** Feedback on one call (its `callId`): a text, a rating 1–5, or both. */ feedback: (callId: string, fb: { text?: string; rating?: number; }) => Promise<void>; telemetry: Telemetry; }; export declare const modelInfo: () => { version: string; talos: string; params: number; dim: number; maxSrc: number; maxTgt: number; vocab: number; ops: string[]; }; export {};