Skip to content

Build an app on Datanotes

This tutorial builds film night, a small app for the diary of classic films. The app is TypeScript on the HTTP API, with the JavaScript client and its own token, and every answer on this page comes from a real run against the engine. By the end, film night does three things:

  • Reads the films you still want to watch
  • Logs a viewing as one undoable change
  • Prints a line each time a film becomes watched

You need:

  • Node.js 24 or newer, which runs .ts files directly
  • An engine that runs on your notes, with npx datanotes serve --database your_database_folder
  • The diary’s tables: film and watch as in Read and filter rows, with the films of that page
  • The datanotes package in the app’s folder: npm install datanotes, with "type": "module" in its package.json

The app reads a view of the films to watch, and the engine refuses a watched film without a rating. Create both once with the owner token:

await owner.call("create_view", {
name: "vw_to_watch",
query: {
table: "film",
columns: ["title", "year", "minutes", "director"],
where: { status: "to watch" },
order_by: "minutes",
},
});
await owner.call("add_constraint", {
table: "film",
name: "rated_when_watched",
check: {
or: [{ status: { ne: "watched" } }, { rating: { exists: true } }],
},
});

owner is a DatanotesClient with the owner token, as in step 1. Store a query as a view and Enforce rules with CHECK constraints explain both operations.

The app has four small files in src/: the client, the films, the change feed and the entry point.

  1. Create the app’s token with the owner token. The engine answers with the token, once, and keeps only its hash:

    import { DatanotesClient } from "datanotes/client";
    const owner = new DatanotesClient({
    url: "http://127.0.0.1:27150",
    token: "your_owner_token",
    });
    const r = await owner.post<{ token: string }>("/tokens/create/", {
    name: "film-night",
    description: "Logs film nights and picks the next film",
    write: ["main.public.watch", "main.public.film"],
    });
    console.log(r.token);

    The token can read every table, and write rows of watch and film only. It can’t change the schema, undo, or touch actor. The engine lists it like this:

    {
    "name": "film-night",
    "description": "Logs film nights and picks the next film",
    "write": ["main.public.watch", "main.public.film"],
    "ddl": false
    }

    Keep the token in an environment variable, FILM_NIGHT_TOKEN.

  2. Create the client in src/db.ts. Every other file imports it:

    import { DatanotesClient } from "datanotes/client";
    export const db = new DatanotesClient({
    url: process.env.DATANOTES_URL ?? "http://127.0.0.1:27150",
    token: process.env.FILM_NIGHT_TOKEN ?? "",
    actor: { harness: "film-night" },
    });

    The engine records film-night as the auth of every write this token makes, whatever actor says.

  3. Read the films to watch in src/films.ts. You select a view like a table:

    import { db } from "./db.ts";
    export interface Film {
    path: string;
    title: string;
    minutes: number;
    }
    export async function filmsToWatch(): Promise<Film[]> {
    const r = await db.call("select_rows", { table: "vw_to_watch" });
    if (!r.ok) throw new Error(String(r.value));
    return (r.value as { rows: Film[] }).rows;
    }

    The view answers with the two films still to watch, shortest first. Each row includes its note’s path, which the next step uses:

    [
    {
    "path": "main/public/film/Battleship Potemkin.md",
    "title": "Battleship Potemkin",
    "year": 1925,
    "minutes": 75,
    "director": "Sergei Eisenstein"
    },
    {
    "path": "main/public/film/Metropolis.md",
    "title": "Metropolis",
    "year": 1927,
    "minutes": 153,
    "director": "Fritz Lang"
    }
    ]
  4. Log a viewing, also in src/films.ts. A batch inserts the watch row and updates the film together:

    export interface Viewing {
    at: string;
    where: "cinema" | "home" | "outdoor screening";
    place: string;
    rating?: number;
    }
    export function logViewing(film: Film, v: Viewing) {
    const { at, where, place, rating } = v;
    const watch = { film: `[[${film.title}]]`, occurred_at: at, where, place };
    const seen = { status: "watched", rating, watched_on: at.slice(0, 10) };
    return db.call("batch", {
    summary: `Film night: ${film.title}`,
    operations: [
    { op: "insert_row", table: "watch", values: watch },
    { op: "update_row", table: "film", path: film.path, set: seen },
    ],
    });
    }

    The engine checks both operations before it writes either. When it refuses one, it writes nothing. The batch is one entry in the journal, so one undo reverts both.

  5. Follow the films you mark watched, in src/feed.ts. The app long-polls the change feed with list_changes, keeping only film changes to status that leave it watched:

    import { db } from "./db.ts";
    interface Change {
    row: { title: string; rating?: number };
    }
    type Page = { events: Change[]; last: number; head: number };
    const watched = {
    tables: ["film"],
    columns: ["status"],
    where: { status: "watched" },
    };
    async function changes(args: object): Promise<Page> {
    const r = await db.call("list_changes", { ...watched, ...args });
    if (!r.ok) throw new Error(String(r.value));
    return r.value as Page;
    }

    followWatched starts at head, the feed’s latest change, so it reports only what happens after the app starts. Each call waits up to 20s for a change, and last is where the next one starts:

    export async function followWatched(onWatched: (c: Change) => void) {
    let since = (await changes({ limit: 1 })).head;
    for (;;) {
    const page = await changes({ since, wait_ms: 20_000 });
    page.events.forEach(onWatched);
    since = page.last;
    }
    }

    The feed suits an app better than a webhook here: a named token can read the feed, while only the owner creates webhooks. To pick up where it stopped after a restart, save last and start from it.

  6. Start the app in src/main.ts. It follows the feed and prints each film you mark watched:

    import { followWatched } from "./feed.ts";
    await followWatched(({ row }) => {
    console.log(`${row.title} is watched, rated ${row.rating ?? "-"}`);
    });

    Run it and leave it running:

    Terminal window
    node src/main.ts

The engine serves no tools of other programs. For agents to pick tonight’s film with one call, film night serves its own Model Context Protocol (MCP) server, with a pick_film_for_tonight tool next to the engine’s read tools and instructions of its own. Agents connect to it as to any MCP server. Build your own MCP server writes that server for this diary, with datanotes/mcp.

src/tonight.ts logs the evening with logViewing, and prints the batch’s journal entry:

import { filmsToWatch, logViewing } from "./films.ts";
const films = await filmsToWatch();
console.log(films.map((f) => `${f.title}, ${f.minutes} min`));
const potemkin = films.find((f) => f.title === "Battleship Potemkin")!;
const r = await logViewing(potemkin, {
at: "2026-10-09T21:00:00",
where: "cinema",
place: "the film club downtown",
rating: 5,
});
if (!r.ok) console.log(r.value);
else console.log((r.value as { migration_id: string }).migration_id);

The script prints the films to watch and the batch’s id:

[ 'Battleship Potemkin, 75 min', 'Metropolis, 153 min' ]
20261009T215615-batch-film-night-battleship-potemkin

A moment later, the running app prints the change it read from the feed:

Battleship Potemkin is watched, rated 5

The diary now has a watch note, main/public/watch/2026-10-09T2100.md, and Metropolis is the only film left to watch.

To revert the whole evening, the owner calls undo_migration with the batch’s id. The app can’t: its token answers token film-night may not call undo_migration (reserved to the owner).

When the engine refuses a write, ok is false and value says why, in words a person can read. The app prints it as it is. This script logs Metropolis twice, first without a rating, then with a rating of 6:

const metropolis = (await filmsToWatch())
.find((f) => f.title === "Metropolis")!;
const evening = {
at: "2026-10-10T20:30:00",
where: "home" as const,
place: "my living room",
};
for (const rating of [undefined, 6]) {
const r = await logViewing(metropolis, { ...evening, rating });
if (!r.ok) console.log(r.value);
}

The first breaks the rated_when_watched CHECK, the second the column’s max: 5. In both cases the engine undoes the watch row of operation 0, and nothing stays written:

batch refused at operation 1 — the operations before it were undone, nothing is left written:
operation 1 (update_row): update_row refused — nothing was written:
invalid main/public/film/Metropolis.md: rated_when_watched violates CHECK rated_when_watched: {"or":[{"status":{"ne":"watched"}},{"rating":{"exists":true}}]}
batch refused at operation 1 — the operations before it were undone, nothing is left written:
operation 1 (update_row): update_row refused — nothing was written:
invalid main/public/film/Metropolis.md: rating must be <= 5

A write outside the app’s permissions comes back the same way. The app may not write actor:

const r = await db.call("update_rows", {
table: "actor",
where: { name: "Brigitte Helm" },
set: { born: "1906-03-17" },
});
if (!r.ok) console.log(r.value);

The engine answers with 403, and the client turns it into the reason:

token film-night may not write main.public.actor

Learn more about tokens, the client and the feed

Section titled “Learn more about tokens, the client and the feed”

These pages cover each part of film night in full: