For Engineering

Extending Laurelin

The 5-step pattern for adding a feature, the migration recipe, and a fully-worked example.

The 5-step pattern

  1. Schema — add the table or columns in laurelin/db.js.
  2. Routes — add the handlers in laurelin/routes.js.
  3. Seed — add demo data in laurelin/seed.js if useful.
  4. UI — add the view in apps/laurelin.jsx.
  5. Deploy — push to main. deploy.sh handles 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:

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:

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