Extending Laurelin
The 5-step pattern for adding a feature, the migration recipe, and a fully-worked example.
The 5-step pattern
- Schema — add the table or columns in
laurelin/db.js. - Routes — add the handlers in
laurelin/routes.js. - Seed — add demo data in
laurelin/seed.jsif useful. - UI — add the view in
apps/laurelin.jsx. - Deploy — push to
main.deploy.shhandles the rest.
That's it. No build step for the frontend (esbuild runs in deploy). No DB migration tool — db.js migrations run on server startup. No router framework — routes.js matches by regex.
Step 1 — Schema
Adding a new table
In initSchema(db) (top of db.js):
CREATE TABLE IF NOT EXISTS new_thing (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
status TEXT DEFAULT 'active' CHECK(status IN ('active','archived')),
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
UUIDs everywhere via crypto.randomUUID() (use the uuid() helper exported from db.js).
CREATE TABLE IF NOT EXISTS means startup is idempotent — running twice does nothing the second time.
Adding a column to an existing table
After initSchema(), add a migration block. The pattern (see db.js ~line 173 for live examples):
// Migration: add review_link to companies (added 2026-03-15)
const cols = db.prepare("PRAGMA table_info(companies)").all().map((c) => c.name);
if (!cols.includes("review_link")) {
db.exec("ALTER TABLE companies ADD COLUMN review_link TEXT;");
}
Rules:
- Wrap in a column-check block. Migrations run on every startup; they must be idempotent.
- No
NOT NULLcolumns on existing tables without a default. SQLite will refuse to add aNOT NULLcolumn to a table that already has rows. - Don't drop columns. SQLite supports it from 3.35+, but our convention is "ignore the column" — leave it in place if it's no longer used. Old code may still write to it.
Changing a column's type or constraint
SQLite doesn't support ALTER COLUMN. The pattern is "build <name>_new, copy data, drop old, rename":
const needsRebuild = (() => {
const info = db.prepare("PRAGMA table_info(projects)").all();
const importanceCol = info.find((c) => c.name === "importance");
return importanceCol && importanceCol.type !== "INTEGER";
})();
if (needsRebuild) {
db.exec(`
CREATE TABLE projects_new ( /* new shape */ );
INSERT INTO projects_new SELECT /* … */ FROM projects;
DROP TABLE projects;
ALTER TABLE projects_new RENAME TO projects;
`);
}
Several worked examples in db.js — search for _new ( or _mig (.
Snapshot the DB before any rebuild. Run bash scripts/backup-laurelin.sh pre-migration-<label> on the host first; deploy auto-snapshots before restart but only if the schema-changing commit triggers a restart.
Step 2 — Routes
In routes.js, add a block (anywhere in the long if-else chain in handleLaurelinRoutes):
// GET — list
if (match("GET", /^\/api\/laurelin\/new-thing$/)) {
const rows = db.prepare("SELECT * FROM new_thing ORDER BY created_at DESC").all();
json(res, 200, rows);
return true;
}
// GET — one
if ((m = match("GET", /^\/api\/laurelin\/new-thing\/([^/]+)$/))) {
const id = m[1];
const row = db.prepare("SELECT * FROM new_thing WHERE id = ?").get(id);
if (!row) {
json(res, 404, { error: "not_found" });
return true;
}
json(res, 200, row);
return true;
}
// POST — create
if (match("POST", /^\/api\/laurelin\/new-thing$/)) {
return readBody(req).then((body) => {
if (!body.name) {
json(res, 400, { error: "name required" });
return true;
}
const id = uuid();
db.prepare(`
INSERT INTO new_thing (id, name, status)
VALUES (?, ?, ?)
`).run(id, body.name, body.status || "active");
json(res, 201, { id });
return true;
});
}
// PUT — update
if ((m = match("PUT", /^\/api\/laurelin\/new-thing\/([^/]+)$/))) {
const id = m[1];
return readBody(req).then((body) => {
const existing = db.prepare("SELECT * FROM new_thing WHERE id = ?").get(id);
if (!existing) {
json(res, 404, { error: "not_found" });
return true;
}
const updates = {
name: body.name ?? existing.name,
status: body.status ?? existing.status,
};
db.prepare(`
UPDATE new_thing SET name = ?, status = ?, updated_at = datetime('now')
WHERE id = ?
`).run(updates.name, updates.status, id);
// If this resource needs audit logging:
// logChanges(db, "new_thing", id, existing, updates, body.changed_by);
json(res, 200, { ok: true });
return true;
});
}
// DELETE
if ((m = match("DELETE", /^\/api\/laurelin\/new-thing\/([^/]+)$/))) {
const id = m[1];
db.prepare("DELETE FROM new_thing WHERE id = ?").run(id);
json(res, 204, null);
return true;
}
Rules:
- Order doesn't matter. First match wins; the file isn't sorted.
logChanges()is required only for the three audited tables:companies,people,projects. Other tables don't need it.- POST/PUT bodies use
readBody(req).then(...)— andreturn trueimmediately so the route is marked handled, then write the response inside the.then. - No raw SQL strings from user input. Always use parameter placeholders (
?).
Step 3 — Seed (optional)
If your feature needs demo data so a fresh DB looks alive, add to seed.js:
db.prepare(`
INSERT OR IGNORE INTO new_thing (id, name, status)
VALUES (?, ?, ?)
`).run("demo-thing-1", "Demo Thing", "active");
INSERT OR IGNORE is the seed pattern. Always.
Step 4 — UI
Three changes in apps/laurelin.jsx:
State
Near the top of the App component, with the other useState calls:
const [newThings, setNewThings] = useState([]);
const [newThingForm, setNewThingForm] = useState({ name: "", status: "active" });
Fetch
In the refresh() function (or its equivalent), add:
api("/api/laurelin/new-thing").then(setNewThings);
The api() helper is at the top of the file — it does fetch + JSON parse + error toast.
View
If your feature is a new tab, add it to the moreTabs array (around line 12049):
const moreTabs = [
// … existing entries …
{ id: "new-things", label: "New Things" },
];
And add the render dispatch (around line 12137):
{activeTab === "new-things" && NewThingsView()}
And define NewThingsView as a function above (you can model it on CompaniesView or ProjectsView).
If your feature is a section within an existing tab, just splice it into that view function.
Step 5 — Deploy
git add laurelin/ apps/laurelin.jsx
git commit -m "Add new_thing feature"
git push origin main
Within 5 minutes, ValinorPC pulls the change, copies backend files into /var/www/team-site/laurelin/, bundles the frontend via esbuild, restarts valinor-intra.service if backend files changed, and the feature is live.
If the schema change is meaningful, also update CLAUDE.md's AUTO-SYNC schema table by running:
node scripts/generate-schema-docs.js
…and committing the resulting docs/reference/schema.md. The next deploy will regenerate it anyway, but committing keeps GitHub viewers up to date.
Fully worked example: adding a "tag" feature to companies
Suppose we want to attach freeform tags to companies. Five steps end-to-end.
1. Schema (db.js)
Companies already have a tags column (TEXT, JSON array). Done. (If they didn't, this would be the column-check pattern.)
2. Route (routes.js)
Find the existing PUT /companies/:id handler. Tags is already in the update payload. Done.
But suppose we also want a list-by-tag endpoint:
if ((m = match("GET", /^\/api\/laurelin\/companies\/by-tag\/([^/]+)$/))) {
const tag = decodeURIComponent(m[1]);
const rows = db.prepare(`
SELECT * FROM companies
WHERE deleted_at IS NULL
AND instr(tags, ?) > 0
ORDER BY name
`).all(`"${tag}"`);
json(res, 200, rows);
return true;
}
Note: instr(tags, '"highvalue"') is a fast text-substring check on the JSON-encoded array. Not perfect, but cheap. For tag-heavy queries you'd add a proper join table.
3. Seed
Skipped — no demo data needed.
4. UI
Add a tag-filter input in CompaniesView:
const [tagFilter, setTagFilter] = useState("");
// …
const filteredCompanies = tagFilter
? companies.filter((c) => {
try {
const tags = JSON.parse(c.tags || "[]");
return tags.includes(tagFilter);
} catch { return false; }
})
: companies;
Wire the filter input into the existing companies grid. Done.
5. Deploy
git add laurelin/routes.js apps/laurelin.jsx
git commit -m "Add tag filtering on companies"
git push
5 minutes later, it's live. The route appears in API reference on the next deploy automatically.
What to avoid
- Express, Knex, Prisma, etc. No frameworks. This is a deliberate constraint to keep the surface area small.
- Async DB calls. better-sqlite3 is synchronous. Don't wrap it in promises.
- Migrations that delete data. Move data into a new table, don't destroy.
- Schema changes without a backup.
scripts/backup-laurelin.sh pre-<label>before any rebuild. - Breaking the audit rule. Every UPDATE on companies/people/projects calls
logChanges(). If you find yourself wanting to skip it, you're working around the audit trail. - Adding to
CLAUDE.mdschema table by hand. Run the generator. It's the source of truth.