Skip to content
WIP

This guide is a work in progress. Barry is also a work in progress.

Add a store

Use this when a bag needs to keep data between calls: notes, a queue, a cache. A bag declares a SQLite store in bag.yaml, writes its schema as SQL migrations, and opens the store with openBagStore from @barry-rocks/sdk/stores. Barry places the file, backs it up with the rest of its stores, and keeps one per machine or one per identity. The example is notes, a bag with tools that add and list notes.

  • Create a bag with a tools.ts.
  • Decide who owns the data. A store owned_by: node holds one copy for the machine. A store owned_by: identity holds one copy for each identity that packs the bag.
name: notes
description: Short notes the agent can add and list, kept in the bag's own SQLite store.
stores:
notes:
owned_by: node
engine: sqlite
migrations: ./migrations/notes

Each key under stores: names one store, in lowercase letters, digits, hyphens, and underscores. Every store needs all three fields: owned_by is node or identity, engine is sqlite, and migrations names a directory inside the bag. A bag with one store can write store: with the same three fields instead; Barry names that store default. A manifest can’t have both.

Each migration is a file named NNN_description.sql, numbered from 001 with no gaps. Migrations only go forward: add a new file for each change, and never edit one that has shipped.

CREATE TABLE notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

The description after the number uses lowercase letters, digits, hyphens, and underscores, and starts with a letter or digit. A file that holds only comments or whitespace fails as an empty migration.

Barry runs every pending migration in one transaction, so a migration can’t contain BEGIN, COMMIT, END, ROLLBACK, SAVEPOINT, RELEASE, VACUUM, ATTACH, or DETACH, except for BEGIN and END inside a CREATE TRIGGER body. Up and down sections aren’t supported. Barry records each migration’s name and a checksum of its SQL, and refuses a store whose history doesn’t match.

Barry bundles the bag before it runs, and the bundle can’t read files beside the bag’s source. So the bag carries the same SQL as data:

import type { Migration } from "@barry-rocks/sdk/stores";
/**
* The SQL under migrations/notes, carried in code because Barry bundles the
* bag and a bundle cannot read files beside its source. Keep each entry
* byte-for-byte equal to its file: Barry checksums both.
*/
export const MIGRATIONS: Migration[] = [
{
name: "001_initial.sql",
sql: `CREATE TABLE notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`,
},
];

Barry migrates the store from the files, and the bag migrates it from this list, so both must hold the same SQL to the byte. A test that compares them, covered in Test a bag, catches a drift before Barry does.

openBagStore opens the file, sets busy_timeout, write-ahead logging, and foreign keys, then applies any pending migrations. It opens lazily, on the first call to db():

import { join } from "node:path";
import Database from "better-sqlite3";
import { bagDataDir } from "@barry-rocks/sdk/services";
import { migratedStorePath, openBagStore } from "@barry-rocks/sdk/stores";
import { MIGRATIONS } from "./migrations.js";
export type NotesDb = Database.Database;
const store = openBagStore({
file: "notes.db",
migrations: MIGRATIONS,
connect: (path) => new Database(path),
scratchGuard: "notes",
path: () =>
process.env.BARRY_NOTES_DB ??
migratedStorePath("notes", "notes", () => join(bagDataDir("notes"), "notes.db"), "notes", "node"),
});
export const getDb = (): NotesDb => store.db();
/** Test seam: use this handle instead. It is migrated first. */
export const setDb = (db: NotesDb): void => store.replace(db);
export const closeDb = (): void => store.close();

The options that matter most:

Option What it does
file The store’s filename, used in migration errors
migrations The list from src/migrations.ts
path Where the file lives, asked on every open
connect Opens the file with the bag’s SQLite driver
identityScoped Keeps one handle per identity, for an owned_by: identity store
scratchGuard Under vitest, refuses a path outside the temp directory

migratedStorePath returns the store’s place in Barry’s active store generation, the one backups and restores carry, and calls the fallback you pass when the installation has none. Its last argument is the owner, node here. For an identity-owned store, pass identity and set identityScoped: true.

Use better-sqlite3, as Barry’s own stores do: every connection to one file within a process must use the same SQLite library. better-sqlite3 is a native addon, so Barry’s build never bundles it. It’s one of the build’s default externals, and at run time the bag loads the copy Barry links from its own installation, so every bag and Barry share one SQLite library. The bag’s own copy serves its types, its editor, and its tests. Pin the same versions of better-sqlite3 and @types/better-sqlite3 that Barry’s SDK pins, so the tests run against the library the bag runs on:

{
"name": "notes-bag",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run"
},
"dependencies": {
"@barry-rocks/sdk": "link:../barry/sdk",
"better-sqlite3": "12.10.0",
"zod": "4.4.3"
},
"devDependencies": {
"@types/better-sqlite3": "7.6.13",
"vitest": "4.1.0"
}
}

Keep the queries in one module, and keep the tools thin:

import { getDb } from "./db.js";
export interface Note {
id: number;
text: string;
created_at: string;
}
export function addNote(text: string): Note {
return getDb().prepare("INSERT INTO notes (text) VALUES (?) RETURNING *").get(text) as Note;
}
export function listNotes(limit = 20): Note[] {
return getDb().prepare("SELECT * FROM notes ORDER BY id DESC LIMIT ?").all(limit) as Note[];
}
import { Barry } from "@barry-rocks/sdk";
import { z } from "zod";
import { addNote, listNotes } from "./src/store.js";
export const add = Barry.Tool({
namespace: "notes",
name: "add",
access: "write",
description: "Add a short note.",
schema: { text: z.string().min(1).describe("The note") },
handler: async ({ text }) => addNote(text),
});
export const list = Barry.Tool({
namespace: "notes",
name: "list",
access: "read",
description: "List the newest notes first.",
schema: { limit: z.number().int().positive().max(100).optional().describe("How many to show") },
handler: async ({ limit }) => listNotes(limit),
});

Install and pack the bag, grant its trait to the identity as Create a bag describes, then add a note and list it:

Terminal
barry install ~/repos/notes
bonanza pack notes
bonanza identity traits add notes
bonanza tool notes add "buy milk"
bonanza tool notes list

The last command prints the note as JSON, with its id and created_at. To see the store file, run bonanza store list. Its entry reads [node] notes:notes, with the file’s size and ver: 1, the number of migrations applied.

  • The store reports a checksum mismatch or a renamed migration. The SQL in src/migrations.ts differs from its file, or a shipped migration changed. Make the two match, and add a new migration for the change instead.
  • The store is at a version this build doesn’t know. A newer build of the bag migrated it. Install that build again; Barry refuses to let older code read a newer schema.
  • A test refuses to open the store. The test points the store at its real location. Set BARRY_NOTES_DB to a temp path, or call setDb with an in-memory database.
  • For anything else, see Troubleshooting.