@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
TypeScript
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 {};