@jackchuka/yamlite
v0.27.0
Published
Sync YAML files and SQLite in both directions
Downloads
5,343
Maintainers
Readme
YAMLite keeps a folder of YAML files and a local SQLite database in sync, in both directions, continuously. Edit a file in your editor and the row changes. Run an UPDATE and the file changes — with your comments still in place.
Why
YAML is great for data humans write: readable, diffable, comment-friendly, git-native. It is terrible to query. SQLite is the opposite. YAMLite lets you keep both without choosing:
- Hand-written data, SQL queries — tasks, notes, contacts, configs, fixtures. Keep editing YAML; ask questions with SQL, Datasette, or any SQLite tool.
- App-written data, git history — let a script or app write to SQLite and get every change as a reviewable YAML diff.
flowchart LR
ED["✏️ your editor"] --> Y["📄 YAML files"]
Y <-->|"yamlite sync / watch"| D[("🗄️ SQLite")]
Y --> G["git diff · pull requests"]
D --> Q["SQL · Datasette · your app"]
Q --> DFeatures
- Two-way, continuous sync —
yamlite watchreacts to file saves and to commits from any process writing the database. - One schema file —
yamlite initwritesyamlite.yamlfrom your data. Commit it next to your YAML: it pins every column's type, and YAMLite keeps the database matching it. - Comment-preserving writes — only changed fields are rewritten; comments, key order and flow style (
[a, b]) survive. - Typed columns —
true→BOOLEAN,1→INTEGER,1.5→REAL, maps/lists →JSON, inferred from your data. - Safe by default — broken YAML is never overwritten or treated as deleted, mass deletions are refused, and the losing side of a conflict is always backed up.
- Rebuildable — the database is a cache. Delete it, run
sync, and it comes back from YAML. - CLI and library — use the
yamlitecommand orimport { open } from "@jackchuka/yamlite". - No native dependencies — built on Node's built-in
node:sqlite.
Installation
Requires Node.js 24.16 or later.
npm install -g @jackchuka/yamlite
# or
pnpm add -g @jackchuka/yamliteOr run it without installing:
npx @jackchuka/yamlite status ./notesQuick Start
Lay out your data. Each entry directly under the root becomes a table:
notes/ ├── tasks/ # directory → one record per file, key = path (subfolders too) │ ├── buy-milk.yaml │ └── write-blog.yaml └── people.yaml # file → a list of records, key = `id`# tasks/buy-milk.yaml title: 牛乳を買う done: false priority: 2 tags: [errand, home]# people.yaml - id: 1 name: Alice team: platform # lead - id: 2 name: Bob team: salesCreate the schema.
yamlite initinfers tables and column types from your files (or an existing database), plus keys, expanded lists and references where the data makes them clear, and writesnotes/yamlite.yaml. Review it and commit it with your data.yamlite init notesPreview, then sync.
yamlite status notes # shows what would change, writes nothing yamlite sync notesQuery and edit with SQL. The database lives at
notes/.yamlite/db.sqlite.sqlite3 notes/.yamlite/db.sqlite "select team, count(*) from people group by team"Or use
yamlite queryto run SQL directly without needingsqlite3:yamlite query "select team, count(*) from people group by team" notesWrites go the other way too — only the changed field is rewritten, and comments stay:
$ sqlite3 notes/.yamlite/db.sqlite "update tasks set done = 1 where id = 'buy-milk'" $ yamlite sync notes > /dev/null && cat notes/tasks/buy-milk.yaml # tasks/buy-milk.yaml title: 牛乳を買う done: true priority: 2 tags: [errand, home]Keep them in sync while you work.
$ yamlite watch notes YAMLite · watching people, tasks in ~/notes · Ctrl-C to stop 12:03:41 tasks → db buy-milk 12:03:55 people → file 2 12:04:02 tasks − file renew-passportIgnore the cache. Add
.yamlite/to your.gitignore— the YAML files andyamlite.yamlare what you commit.
Commands
| Command | Options | Description |
| ---------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------- |
| yamlite init [root] | --db, --force, --print | Create yamlite.yaml from your files and database |
| yamlite guide | | Print the guide to writing yamlite.yaml, for people and AI agents |
| yamlite check [root] | --table <name>, --json | Check the YAML files against yamlite.yaml; exits 1 on any problem |
| yamlite query <sql> [root] | --db, --format (table, json, csv), --limit (1000) | Run one read-only SQL statement and print the rows |
| yamlite status [root] | --db, --table <name>, --json | Show what sync would change, without writing |
| yamlite sync [root] | --db, --table <name>, --force, --force-convert, --json | Sync once; exits 1 if any table fails |
| yamlite watch [root] | --db, --quiet | Sync on every file save, database commit or config change |
| yamlite serve [root] | --db, --port (4610), --host (127.0.0.1), --open, --no-agent | Open the web UI and sync continuously |
| yamlite export [root] | --out (.yamlite-export), --table, --force | Write the web UI as a read-only static site |
rootdefaults to the current directory.--db <path>overrides the default<root>/.yamlite/db.sqlite.--table <name>can be repeated.--forceallows mass deletions that the deletion guard would refuse.--force-convertclears values that cannot be converted when a column type changes (see Column types).--jsonprints the raw results (per table: counts,changesper key,conflicts,warnings) for scripting.check --jsonhas its own shape (see Check).- Output is colored on a terminal; set
NO_COLOR=1to disable it.
Web UI
yamlite serve notes --openserve runs watch and a local web UI in one process: browse and edit records with forms that fit each column type, check each table's columns, references and indexes, see every table, view and reference on one diagram (ERD), run SQL, and see sync activity, warnings and conflicts as they happen. Edits from the UI are written to the database and reach your YAML files through the same sync as any other app, so comments and the safety checks all apply. A conflict's losing side can be restored from the Sync page.
The UI also works on a phone: below 768px wide the sidebar moves behind a menu button and records open full screen. To reach it from another device, start serve with --host 0.0.0.0.
The UI is available in English and Japanese. It follows your browser language on the first visit; switch it from the language menu in the status bar.
- It listens on
127.0.0.1:4610and prints a URL with an access token (new for each run, reusable until the process stops); open that URL (or pass--open). Requests without the token, from other sites, or with an unexpectedHostare refused. serveandwatchcannot run on the same folder at the same time.--host 0.0.0.0exposes it to your network; anyone with the URL can then change your data.- The ERD page marks references with problems. Click a table to highlight its neighbours, or double-click it to open the table. The layout is recomputed only when the tables, columns or references change, so nodes you drag stay where you put them.
- A record's History tab lists the git commits that changed it, newest first, with uncommitted changes on top: each shows the fields it changed, and the YAML diff on request. Renames are followed, and in a list file only the commits that changed that record are shown. Restore this version puts an older version's values into the form without saving them. It needs
gitand only reads the repository; the static export has no History tab. - Edit rules in a table's schema changes its
required,values,formats,min/maxandreferences, or an expanded view's, and can also add or remove the table's indexes and its expanded views. Each change is checked against your data first ("Saving adds 2 warnings"), and only Save writesyamlite.yaml, keeping its other lines and comments. Types, keys and paths stay in the file. Ifyamlite.yamlchanged since you started, saving is refused and your draft is kept.
Send for review
When the data folder is in a git repository with an origin remote, the bottom of the sidebar shows the current branch and how many files under the data folder changed. Send for review lists the changed files by table and record, asks for a title and an optional description, and sends the files you keep checked:
- On the default branch, it creates a branch with the name you enter, or
yamlite/review-<date>-<time>when you leave it blank, commits the files there, pushes it and opens a draft pull request. The UI stays on the new branch, so later edits go to the same pull request. - On any other branch, it commits and pushes to that branch. If the branch already has an open pull request, it links to it; otherwise it opens a draft pull request.
- With
ghinstalled and logged in, the pull request is created for you. Without it, you get a GitHub link that opens the pull request form already filled in. For other hosts the branch is pushed and you open the pull request there. - Only files under the data folder are committed. Git runs without prompts, so pushing needs credentials that already work without asking.
- Each record is listed under its file, with the fields it changed; the arrow shows them as committed and now. ↶ puts a changed or deleted record back as committed, and the bin removes a record that was never committed, after a second click. These go through the same writes as the form, so comments and the safety checks apply. Put back does it for the changed and deleted records of the checked files. The toast that follows can undo it.
yamlite.yaml, pages and other files are listed but never changed. - While it runs, edits and other git steps wait (they get 409). Switching back to the default branch after the pull request is merged is up to you.
Click the folder name at the top of the sidebar to see its full path, yamlite.yaml, the database file and the address.
Ask AI
Set ANTHROPIC_API_KEY or OPENAI_API_KEY before yamlite serve, or enter a key in the Ask AI panel. Ask in plain words — "list the tasks due next week", "mark every errand task done" — and the model searches with SQL and proposes changes. A proposal shows each record's changes in the panel and in the table; nothing is written until you press Apply, and then it is saved like an edit in the form, so comments and the safety checks apply.
- Models: the panel suggests the ones checked with YAMLite — Anthropic
claude-sonnet-5-5(default),claude-haiku-5-5,claude-opus-5-5; OpenAIgpt-5,gpt-5-mini— and takes any other model id the provider offers. Other models may not use the tools well. - A key entered in the panel is kept in the browser only if you tick "Remember the key on this device", and is sent only to your
yamlite serveand from there to the provider. What you ask, and the data the model reads to answer, go to that provider. - The model can only use YAMLite's tools: read the schema, run read-only SQL, read records, check rules, read the guide, and create proposals, and, in a git repository, the git tools below. It cannot run commands or write files.
- A proposal is refused, with nothing written, if a record in it changed after it was made. It holds at most 500 rows. If applying would add rule warnings (required, allowed values, formats, bounds), the proposal shows them; they don't block applying, as with any other edit.
- With
--host 0.0.0.0, anyone with the URL's token can use Ask AI, and it spends the API key thatserveread from the environment. - Conversations stay listed under Conversations while
serveruns; conversations and pending proposals are kept in memory, so stoppingservediscards them. - When the folder is in a git repository, the agent can also read its status, diffs, log and branches, and propose git steps: create or switch a branch, commit files it picks, push a branch, fast-forward-only pull, and open a draft pull request. The card lists every step and the files of each commit; nothing runs until you press its button, and the steps stop at the first failure. Branch names must be plain short names (no leading
+or-, norefs/orheads/prefix, no@). Push never forces, and pushing to the default branch is refused, as is pushing when the default branch cannot be determined. - While git steps run, edits in the UI and other proposals are refused until they finish.
- Push and pull use your own git setup (SSH keys or credential helper); YAMLite never asks for a password. Pushing and opening a pull request publish your data outside this machine.
- If GitHub CLI is installed and logged in, the pull request is created for you and linked in the chat. Without it, the card links to GitHub's pull request form with the title and body filled in.
- Switching branches changes the YAML files, and the database follows them, like an edit in your editor, even when many records disappear. If a declared table's folder doesn't exist on the target branch, the switch still happens but the database sync fails, and the card says so.
Pages
Add your own screens — a kanban board, a dashboard — to serve. A page is one HTML file that reads and writes your data through window.yamlite, limited to what yamlite.yaml allows it.
pages:
board:
path: .pages/kanban.html # a dot-folder: a plain folder under the root would become a table
title: Task board
access: { tasks: write, people: read } # tables and views; nothing else is reachable
sql: false # true allows SELECT over the names in access
network: [] # origins the page may load scripts, styles, images and fonts from, and connect toStart from examples/pages/kanban.html: copy it into .pages/, set TABLE, KEY, GROUP and TITLE at the top, and declare it as above.
| Call | Does |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| await yamlite.ready | { page, title, access, sql, network, readOnly } |
| yamlite.rows(table, { filter, sort, search, limit, offset }) | { rows, total }, reads tables and views, same filters and search as the table screen; limit ≤ 500 |
| yamlite.get(table, key) | { row, file }; tables only, a view gives 400 |
| yamlite.create(table, key, values) / update(table, key, values, base) / remove(table, key) | needs write; update fails with status 409, extra.stale (the fields) and extra.current (their values now), if a field changed since base |
| yamlite.sql(select) | needs sql: true; one SELECT over the names in access |
| yamlite.on("change", ({ tables }) => …) | called after a sync changes a table or view the page can read |
| yamlite.theme / yamlite.on("theme", ({ theme }) => …) | the current theme (light or dark), and a call when it changes |
| yamlite.open(table, key) | opens the record in YAMLite's own form; tables only, a view gives 400 |
Errors reject with yamlite.YamliteError (status, message, extra).
Look and feel
Every page gets the UI's colours and fonts as CSS variables, and follows the theme switch without reloading:
| Variables | Use |
| ------------------------------------------------------------------- | -------------------------------------------------------------------- |
| --y-bg, --y-panel, --y-panel-2, --y-line | surfaces and borders |
| --y-text, --y-muted | text |
| --y-accent, --y-accent-fill, --y-accent-soft, --y-on-accent | emphasis |
| --y-ok, --y-warn, --y-warn-soft, --y-err, --y-err-soft | states |
| --y-chip, --y-chip-text | chips |
| --y-font, --y-mono | Outfit and JetBrains Mono (Latin); other scripts use the system font |
Native form controls and scrollbars follow the theme (color-scheme) only on pages with data-yamlite-ui.
<html> carries data-theme="light" or "dark"; yamlite.theme reads it and yamlite.on("theme", ({ theme }) => …) is called when it changes.
Add data-yamlite-ui to <html> to also get the UI's look for plain elements — body, headings, links, button (.y-primary for the filled one), input, select, textarea, table (sticky header, wrapping cells), pre/code, details/summary — and these classes: .y-chip (with .y-ok, .y-warn, .y-err), .y-card, .y-toolbar, .y-muted, .y-link. All of it sits inside :where(), so any rule of your own wins.
The variable and class names are part of the page API: renaming one is a breaking change.
- The page runs in a sandboxed frame with no access to YAMLite's session; every call goes through YAMLite, which checks it against
access. SQL runs on a read-only connection where SQLite itself refuses tables outsideaccess. networkis enforced with a Content Security Policy. It limits what the page loads and where it connects. Declaring an origin lets the page send data, including anything it can read throughaccess, off your machine to that origin.- Every page, even with
network: [], can leak data by navigating its own frame to another site with the data in the URL. YAMLite then disconnects the page, but the data has already left. Only declare pages you trust, as you would any script you run. - In an export pages are read-only, and
accessdoes not hide data: the exporteddb.sqliteis downloadable, andsql: truereads every exported table. Pages whoseaccessnames a table left out of the export are skipped with a warning.
Check
yamlite check notescheck syncs your YAML into a temporary database, the way export does, and reports every problem it finds: a table that fails, any warning (values, required, formats, min and max, references, types, indexes), and a table or column that yamlite.yaml does not list yet. It exits 1 if there is one. Nothing under the root is written and no lock is taken, so it runs in CI, in a pre-commit hook, and next to a running watch or serve.
--table(repeatable) checks only those tables.--jsonprints{ ok, tables: [{ table, ok, error?, warnings, unregistered }], unregisteredTables }.
# .github/workflows/check.yml
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 24 }
- run: npx @jackchuka/yamlite check .#!/bin/sh
# .git/hooks/pre-commit (chmod +x)
exec npx @jackchuka/yamlite check .Query
yamlite query "select status, count(*) from tasks group by status" notes
yamlite query "select * from people" notes --format csv > people.csvquery runs one read-only statement (SELECT, WITH … SELECT, EXPLAIN, VALUES) without needing sqlite3. Like check, it syncs your YAML into a temporary database first, so it always reads what the files say, writes nothing under the root, and runs next to watch or serve. A table that fails to sync is left out of that database, so querying it errors with no such table; the failure and any sync warnings go to stderr.
--db <path>reads that database as it is instead, without syncing — for example.yamlite/db.sqliteto see what an app wrote but has not synced yet.--formatistable(default),json(one object per row; a repeated column name gets_2,_3, …) orcsv(LF line endings).--limit(default 1000) caps the rows;0removes the cap. The row count and any truncation go to stderr, so stdout stays clean for pipes.- To write, use
sqlite3, your app or the web UI: the database is opened read-only.
Export
yamlite export notes --out ../notes-siteexport writes the web UI as a static site you can put on any web server: browse tables and views, open records with their YAML, filter, sort, see the ERD, and run SELECT in the SQL console. Nothing can be written: there are no save, delete or new buttons, no Sync page, and the console refuses anything but SELECT, EXPLAIN and VALUES. The data runs in the browser with sql.js.
- It syncs your YAML into a temporary database, so it works in CI without
.yamlite/and never touches the root: noyamlite.yamlchanges, no.yamlite/folder, no lock. --table(repeatable) limits the export to those tables; the others are left out of the database too.- Output:
index.html,assets/, anddata/(snapshot.json,db.sqlite, oneyaml/<table>.jsonper table, holding each YAML file's text once). Every URL is relative, so it works under a subpath such as GitHub Pages'/<repo>/. - Machine paths are hidden: the root is shown as its folder name, and paths outside the root as their base names.
--outdefaults to.yamlite-export. It cannot be the data root, an ancestor of it, or.yamlite/(also when reached through a symlink or a different letter case), and inside the root it must sit in a dot-folder (YAMLite ignores those; a plain folder would become a table on the next sync).- An existing
--outfolder is replaced only if it holds a previous export or is empty;--forcereplaces anything else, but never the root, its ancestors or.yamlite/. - Open it over HTTP (
npx serve ../notes-site); browsers do not load the app fromfile://, so the page opened that way only says to use a web server. - Set
SOURCE_DATE_EPOCHto make the output byte-for-byte reproducible.
Publishing to GitHub Pages:
# .github/workflows/pages.yml
on: { push: { branches: [main] } }
permissions: { contents: read, pages: write, id-token: write }
jobs:
deploy:
runs-on: ubuntu-latest
environment: github-pages
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 24 }
- run: npx @jackchuka/yamlite export .
- uses: actions/upload-pages-artifact@v3
with: { path: .yamlite-export }
- uses: actions/deploy-pages@v4Working with your data
Tables
| Under the root | Table | Records | Key |
| -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| tasks/ (directory) | tasks | one per *.yaml / *.yml file in the folder and its subfolders; the same as files: "tasks/**/*.{yaml,yml}", which you only write to change it | the path without extension (id): buy-milk, archive/buy-milk |
| people.yaml (file) | people | each item of the top-level list | the id field |
Dotfiles, dot-folders and node_modules are ignored at every level, and yamlite.yaml at the root. Symlinked files and directories are followed.
In a directory table, a file's own id field may hold the full key (archive/buy-milk) or just the file name (buy-milk); anything else is a warning and the path wins. Inserting a row with a nested key creates its folders, and deleting the last record in a folder removes the emptied folder. A table declared inside another one's folder takes the files its glob matches (archive: { files: "tasks/archive/*.yaml" }), and the outer table leaves them alone. Renaming a folder deletes and re-adds its records, so renaming a large folder can trip the mass-deletion guard and need --force (see Safety).
Types
| YAML value | Column type | Stored as |
| ----------------------- | ----------- | ---------------------------------- |
| true / false | BOOLEAN | 1 / 0 |
| 42 | INTEGER | integer (big values stay exact) |
| 1.5, or ints + floats | REAL | real |
| hello, 2026-10-01 | TEXT | text (YAML 1.2: dates are strings) |
| maps and lists | JSON | JSON text |
New YAML keys become new columns automatically. Inference never changes an existing column's type (scalar values that don't fit are stored as-is with a warning); to change it, declare the type in yamlite.yaml — see Column types.
Nested values
Nested data stays in one JSON column per top-level key. Use SQLite's JSON functions:
# tasks/release.yaml
title:
en: Write release notes
ja: リリースノートを書くselect json_extract(title, '$.ja') from tasks; -- read a nested value
update tasks set title = json_set(title, '$.ja', '書き直す'); -- written back as a map
select * from tasks, json_each(tags) where json_each.value = 'a'; -- filter on list itemsA field must be always a scalar or always a map/list across records. Mixing them in a new field stops the table with an error listing which records are which; a record that doesn't fit an existing column is skipped with a warning. If a JSON column in the database holds something that isn't a map or list (for example after a manual UPDATE), that row is not written back to YAML until you fix it in SQL or edit the file. To migrate a field (say, title → {en, ja}), declare it in yamlite.yaml (columns: { title: JSON }) and sync: the column is converted, and every record not yet converted shows up as a warning until you change its file.
Expanded views
Lists inside records — milestones of a project, tasks of a milestone — can be queried as rows. Declare them under expand, and every sync keeps a read-only SQLite view per list:
tables:
projects:
expand:
milestones: # the JSON column → view projects__milestones
columns: { points: INTEGER } # optional: pin a type
expand:
tasks: # a field of each milestone → view projects__milestones__tasks
references: { owner: people }select p.title, m.title, t.owner
from projects p
join projects__milestones m on m.projects_id = p.id
join projects__milestones__tasks t on t.projects_id = m.projects_id and t.milestones_idx = m.idx;- A view's first columns name the row: the table's key (
projects_id), the positions of the lists above it (milestones_idx; a repeated name gets_2,_3, … as inchildren_idx_2), and its own position —idxfor a list,keyfor a map. Then one column per field of the items; items that aren't maps (tags: [a, b]) go to avaluecolumn. - Columns and types are inferred from the data on every sync;
columnspins a type. Nothing is written back toyamlite.yaml. - Views are read-only: edit the YAML, or update the JSON column of the table.
servelists them under their table and opens the record a row comes from. referenceson a view are checked like a table's, andstatusshows planned+ view/− viewchanges. A list that can't become a view (the field isn't a JSON column, or a view of that name was made by hand) is a warning.- The web UI can add or remove expanded views from the table's schema (Edit rules); removing one also removes its rules.
Use with AI agents
yamlite guide prints a guide to writing yamlite.yaml — the workflow (init, status, check), how to lay out tables, and when each rule fits — matching the installed version. Point any coding agent at it, or install it as an Agent Skill for Claude Code, Codex, Cursor and others:
npx skills add jackchuka/yamliteThe skill tells the agent to read yamlite guide and to finish with yamlite check passing. The Ask AI panel in yamlite serve reads the same guide.
Configuration
<root>/yamlite.yaml (or yamlite.yml) is required — it is the schema of record. Create it with yamlite init, commit it with your data, and edit it to change keys, types, indexes or to add tables that live elsewhere. YAMLite keeps it current for you: when sync or watch meets a new table under the root, a new YAML key, or a column an app added to the database, it appends it to yamlite.yaml (existing lines, comments and order are left untouched) — so new schema shows up as a reviewable diff. yamlite status lists these as + column … (will be added to yamlite.yaml) first. yamlite watch reloads it on save — and picks up tables added under the root — without a restart; a broken file is reported and the previous configuration stays in effect.
tables:
people:
key: slug # default: id
group: CRM # the web UI's sidebar section
split: team # the web UI's sidebar lists one item per value
values: { team: [platform, sales] }
columns: { team: TEXT, age: INTEGER, tags: JSON } # INTEGER | REAL | TEXT | BOOLEAN | JSON
formats: { bio: markdown } # how the web UI edits a TEXT column
expand: { tags: {} } # lists as views — see Expanded views
indexes:
- [team, age] # composite index
- { columns: slug, unique: true }
- { expr: "json_extract(meta, '$.ja')" } # index a nested value
inbox:
files: ~/Dropbox/inbox/*.yamlfiles: takes a glob from the root: everything before the first wildcard is the table's folder (it may be outside the root), and the key is the path below it without the extension. files: "**/*.{yaml,yml}" makes the whole root one table; the conventions then add no folder tables under it, and yamlite.yaml itself is never a record. A file two tables match belongs to the one whose folder is deeper; two tables with the same folder matching the same file is an error. path: is for a single YAML file holding a list of records — a folder there is an error that names the files: line to use instead.
Markdown files
A files: glob ending in *.md or *.mdx makes a Markdown table: the YAML front matter between the --- lines at the top of each file becomes columns, and the text below it goes into a body column, which the web UI edits as Markdown.
tables:
notes:
files: "**/*.md" # an Obsidian vault, notes at any depth
posts:
files: "src/content/blog/**/*.md"
body: content # name the body column something else
docs:
files: "content/**/*.mdx" # MDX: JSX in the body is kept as text- The web UI previews the body as rendered Markdown. For
*.mdx, components,{expressions}andimport/exportlines are shown as written, never run. - A file without front matter is a record with only a body; adding a field adds a front matter block.
- Writes change only what changed: editing only the body leaves the front matter text as it was, and editing a field leaves the body's bytes alone. A field edit rewrites the front matter the way it does for a YAML file: comments mostly stay on their lines, but a comment-only block loses its comment when the first field is added, and spacing or number formatting on other lines can be normalised. CRLF line endings and a BOM are kept.
- Only YAML front matter is read. A file with TOML (
+++) or JSON front matter is skipped with a warning and never written. - A Markdown table exists only where
yamlite.yamldeclares one.yamlite initdeclares one for each top-level folder that holds.mdor.mdxfiles and is not a YAML table, at the deepest folder holding them all and one per extension;syncnever creates one, and a README next to your YAML or at the root stays a README.
Captured columns
A <name> folder in files: matches one folder like *, and its name becomes a column:
tables:
notes:
files: "notes/<team>/<project>/*.md"
split: team
references:
team: teamsnotes/platform/search/plan.md is a row with team: platform and project: search. The column is always TEXT: 01 and 1 are different folders.
- It is never written to a file: the folder is its only source. An edit in the database or the web UI is undone on the next sync with a warning, and the web UI shows the field read-only. To move a record, move its file.
- A file field with the same name is left in the file but ignored, with a warning when it differs from the folder.
- New record under a split item of a captured column starts its key in that folder (
platform/). - A capture is a whole folder (
<year>-*is not one), cannot be the file name or come after**, and cannot be named like the key or body column.
Field paths
split, values, required, min, max and references take column.field for a field inside a JSON column, so front matter like metadata: { status: draft } can be checked and browsed without its own column:
tables:
notes:
files: "notes/**/*.md"
columns: { metadata: JSON }
split: metadata.status
values:
metadata.status: [draft, review, done]
required: [metadata.reviewer]- A name that is a column is that column, so a column named
a.bkeeps working. Otherwise the part before the first.must be a JSON column incolumns. - A list at the path is checked item by item, and
splitlists each item. minandmaxon a field path take numbers.metadatastays one JSON column in the database, the same shape as in the file.
Removing columns
Delete a column's line from yamlite.yaml and the next sync drops it from the database (with any yamlite-managed index on it). If YAML files still have that key, a file can't be read, or a row in the database still holds a value in that column (an unsynced or app-written value), the column is kept and listed in a warning until you resolve it or add the line back. Columns that were never declared — say, one an app added — are registered instead, never dropped.
Column types
columns is declarative too. When a declared type differs from the column in the database, the next sync rebuilds the table with the new type (in one transaction, following SQLite's recommended procedure) and shows ↻ column title TEXT → JSON beforehand in yamlite status.
- Values are converted where possible (
1↔"1","true"→BOOLEAN, JSON text →JSON). - A value that can't be converted is cleared and refilled from YAML, which stays the source of truth. If that value was changed in SQL and never synced, the rebuild stops instead; fix the row or pass
--force-convertto clear it. - Tables with triggers, CHECK, UNIQUE or foreign-key constraints, COLLATE, AUTOINCREMENT, generated columns,
STRICTorWITHOUT ROWID, indexes YAMLite doesn't manage, or other tables referencing them are never rebuilt; YAMLite reports an error and leaves the migration to you. - The key column's type is never changed; to change it, recreate the database (delete
.yamlite/).
Groups
group puts a table under a named section in the web UI's sidebar; it never changes the database. Grouped tables follow the ungrouped ones, and groups are listed in the order their first table appears. A section folds away with a click and stays folded in that browser. The table header's Group button sets or clears a table's group from the UI, writing group: into yamlite.yaml, and New table takes one too.
Split
split names a column whose values the web UI's sidebar lists under the table, each with its count; it never changes the database. A table of notes with split: type stays one table, and the sidebar shows idea, todo and so on under it. Clicking one opens the table filtered to that value, and New record there starts with the value filled in. A JSON column lists each element of its arrays, so a record tagged [a, b] is under both a and b. Records with no value are under "(none)", shown only when there are some. The items fold with the table's views behind the arrow on its row: they start folded, an open table stays open in that browser, and a table opens by itself while one of its items or views is shown. The column must be declared in columns (or be a field path into one), and cannot be the key or the Markdown body.
Formats
formats tells the web UI how to edit a TEXT or JSON column, and for all but markdown also what to check:
| Format | Column | Edited with | Checked |
| ---------- | ------ | --------------------------------------------- | ------------------------------------------------------------------------- |
| markdown | TEXT | rendered Markdown, with an Edit tab | — |
| date | TEXT | a date picker | YYYY-MM-DD, a real date |
| datetime | TEXT | a date and time picker, with the offset shown | YYYY-MM-DD, T or a space, HH:MM[:SS[.fff]], optional Z / ±HH:MM |
| list | JSON | chips for a list of text, or JSON | a list |
| map | JSON | keys and values, else JSON | a map |
The Markdown editor keeps the text exactly as you type it, so saving doesn't reformat the Markdown in your YAML. A datetime keeps how it is written: picking a new time leaves its offset, separator and seconds as they were, and a new value gets your browser's offset. A value the picker can't show is edited as text, and a button switches between the two.
A value that doesn't match its format is a warning, not a failed sync:
! due "2026-02-31" not a date (buy-milk)
! tags {"a":1} not a list (buy-milk)A new record starts a map field with its editor, and offers a list field only chips or JSON (for a list of maps). Only the column's own value is checked, not what it holds: to check the items of a list, or the values of a map, expand it and give the view its own formats:
tables:
projects:
formats: { milestones: list }
expand:
milestones:
formats: { tags: list, meta: map }init writes date or datetime for a TEXT column whose values all have that shape. A format on a column declared with another type is an error, and so are values, min and max on a list or map column. expand entries take formats too.
Values
List the values a column may hold, and YAMLite checks them on every sync. Like references, they are never enforced: a value outside the list is a warning, not a failed sync.
tables:
tasks:
values:
status: [todo, doing, done]
tags: [errand, home, work] # each item of a list is checked
expand:
milestones:
values: { state: [open, closed] }! status "doen" not in values (buy-milk)- Values are compared as text (
1matches"1"), numbers also as numbers (2.0matches2), andtrue/falseas the1/0aBOOLEANcolumn stores. A missing field is not checked. - The web UI edits the column with a select. A value outside the list stays in it, marked, until you change it.
- A
markdowncolumn cannot have values.
The web UI can edit them from the table's schema (Edit rules).
Required
List the columns every record must have a value in, and YAMLite checks them on every sync. A missing key or null is a warning, not a failed sync; "" and [] count as values.
tables:
tasks:
required: [title, status]
expand:
milestones:
required: [state]! title missing (buy-milk, call-mom)The web UI marks required fields with * and highlights the empty ones. yamlite check fails on any of these warnings.
The web UI can edit them from the table's schema (Edit rules).
Min and max
Bound a column, and YAMLite checks it on every sync. Bounds are included, and a value outside them is a warning, not a failed sync.
tables:
tasks:
columns: { priority: INTEGER }
formats: { due: date }
min: { priority: 1, due: 2026-01-01 }
max: { priority: 5 }! priority 7 above max 5 (buy-milk)
! due "2025-12-31" below min 2026-01-01 (call-mom)- A bound takes a number on an
INTEGERorREALcolumn, and a date (or, fordatetime, a datetime) on adateordatetimecolumn; anything else is an error. Inexpand, declare the column's type there too (columns: { points: INTEGER }). - Datetimes compare as points in time: one without an offset is UTC, and a date bound is its midnight UTC.
- A value of the wrong kind (text in an
INTEGERcolumn, a broken date) is not compared; the format check reports a broken date. - The web UI marks a value out of bounds under its field.
The web UI can edit them from the table's schema (Edit rules).
References
Declare which fields point at other tables, and YAMLite checks them on every sync — it never enforces them, so a typo or a record you haven't added yet is a warning, not a failed sync:
tables:
tasks:
references:
assignee: people # matches people's key column
project: projects.slug # matches a specific column
tags: labels # each item of a list is checked! assignee "9" not found in people.id (buy-milk, call-mom)Values are compared as text ("1" matches 1). While watching, a change to people re-checks the tables that reference it. These are not SQLite foreign keys: nothing is rejected or cascaded, and you fix the YAML.
The web UI can edit them from the table's schema (Edit rules).
Indexes
indexes is declarative: every sync makes the database match it. YAMLite creates missing indexes and drops the ones you removed from the list, and leaves indexes you created yourself in SQL alone (its own are named yamlite_<table>_<hash>). An index that can't be created — a duplicate value for a unique index, or a column that doesn't exist yet — is reported as a warning and data keeps syncing. yamlite status shows planned index changes as + index / − index.
The web UI can add or remove indexes from the table's schema (Edit rules).
Safety
YAMLite is built so that a sync never silently loses data:
- Unreadable files are untouchable. A YAML file with a syntax error, several documents, or the wrong top-level shape is never overwritten and never treated as a deletion: a record file is skipped, and a broken list file stops its table with an error. A subfolder that can't be read and a symlink whose target is gone also stop their table, since they may hold records. Fix it and the next sync picks it up.
- Mass deletions are refused. A sync that would delete more than
max(10, 50%)of a table, or empty a table that had several records, stops unless you pass--force. A missing folder or a dropped table with previously synced records is an error, not a wipe. - Conflicts keep both sides. If a record changed in YAML and in the database, the newer change wins and the other version is saved to
.yamlite/conflicts/<table>/. The file's modification time is compared with the database's last write (any row, in any table), so the database wins whenever it was written after the file was saved. Each backup starts with a# yamlite: {...}comment line that records the table, the key, which side won and when. - Concurrent edits are detected. Files are rewritten atomically and only if they are unchanged since they were read; database writes run in a transaction.
- One writer at a time. A lock in
.yamlite/keeps twosync/watchprocesses from running together (statusis always allowed).
How it works
flowchart LR
Y[YAML files] -- chokidar --> E
D[(SQLite)] -- PRAGMA data_version --> E
E{{"3-way compare per record<br/>file hash · db hash · last synced"}}
E -- changed in YAML --> D
E -- changed in SQLite --> Y
E -- changed in both --> C[newer wins<br/>loser → .yamlite/conflicts]For every record, YAMLite compares the current file hash and row hash with the hashes recorded at the last sync (stored in the _yamlite_state table). Only the side that changed is propagated, and each side is re-read after writing, so YAMLite never echoes its own writes back or loops. Apart from three bookkeeping tables (_yamlite_state, _yamlite_columns and _yamlite_views) and the views you declare under expand, nothing is added to your database — no triggers, no change log.
Library
import { open } from "@jackchuka/yamlite";
const y = await open({ root: "./notes" }); // or { db: "./app.db", tables: [{ name: "tasks", files: "./tasks/**/*.yaml" }] }
const plan = await y.status(); // what would change
const results = await y.sync(); // [{ table, ok, toDb, toFile, conflicts, warnings, ... }]
const watcher = y.watch({
onSync: (r) => console.log(r.table, r.toDb, r.toFile),
onConflict: (c) => console.warn(`conflict in ${c.table}/${c.key}, saved ${c.savedTo}`),
onError: (e) => console.error(e),
});
await watcher.ready;
await y.close();Open your own node:sqlite (or any SQLite) connection to write to the database — watch picks up commits from other connections automatically.
Limitations
- Local, single-machine use. One YAMLite process per data folder.
- Sized for hundreds to thousands of records per table; every sync scans the whole table.
- No SQLite foreign-key constraints (references are checked, not enforced). Only YAML is read:
.mdand.mdxfiles through their YAML front matter, never TOML or JSON front matter or other formats. - A table splits by one column only.
- When a file is rewritten, the space before an inline comment is normalized to one (
a: 1 # note→a: 1 # note).
