Build Verse: a poem-quote app on the agent-native chat template
CI / build (push) Successful in 4m26s

Adds a Postgres-backed quotes domain (poems/quotes/favorites) exposed as
five agent-native actions (list-quotes, get-random-quote, create-quote,
toggle-favorite, list-favorites) callable from both chat and the React UI
via useActionQuery/useActionMutation. Moves the primary UI from chat to a
mobile-first Quotes/Favorites experience (chat moves to /chat), adds
manual OpenTelemetry instrumentation exporting traces/metrics/logs over
OTLP, and wires a local Docker Postgres for shared state.
This commit is contained in:
exe.dev user
2026-07-25 17:42:04 +00:00
parent ffea2aeff7
commit 0a18c774a5
44 changed files with 14702 additions and 156 deletions
+5 -6
View File
@@ -1,17 +1,16 @@
import { createAuthPlugin } from "@agent-native/core/server";
const rawAppTitle = "App";
const rawAppTitle = "Verse";
const appTitle = rawAppTitle === "{" + "{APP_TITLE}}" ? "Chat" : rawAppTitle;
export default createAuthPlugin({
marketing: {
appName: appTitle,
tagline:
"Start from a chat-first agent-native app and add actions, screens, and workflows as you grow.",
tagline: "Quotes drawn from poems — browse, search, and save your favorites.",
features: [
"Full-page chat with durable threads and tool call history",
"Add actions once and use them from chat, UI, HTTP, MCP, A2A, and CLI",
"Plug in your own agent runtime or build on the included app-agent loop",
"Curated and community-submitted quotes from classic poems",
"Search and filter by poet or theme, favorite the ones you love",
"An agent chat that can look up, add, and organize quotes for you",
],
},
});
+143
View File
@@ -0,0 +1,143 @@
import {
ensureAdditiveColumns,
getDbExec,
runMigrations,
isPostgres,
} from "@agent-native/core/db";
import { db } from "../db/index.js";
import * as schema from "../db/schema.js";
import { SEED_POEMS } from "../db/seed-data.js";
function pk(): string {
return isPostgres() ? "SERIAL PRIMARY KEY" : "INTEGER PRIMARY KEY";
}
function boolType(): string {
return isPostgres() ? "BOOLEAN" : "INTEGER";
}
function boolDefault(): string {
return isPostgres() ? "false" : "0";
}
/**
* Every Drizzle table exported from schema.ts. Filters out type-only and
* helper exports the same way the framework's own templates do: a real
* table carries a Symbol-keyed drizzle metadata bag, plain exports don't.
*/
function isDrizzleTable(value: unknown): value is object {
return (
!!value &&
typeof value === "object" &&
Object.getOwnPropertySymbols(value).some((s) =>
s.toString().includes("drizzle"),
)
);
}
const schemaTables = Object.values(schema).filter(isDrizzleTable);
const runVerseQuoteMigrations = runMigrations(
[
{
version: 1,
name: "verse-quote-app-poems-quotes-favorites",
get sql() {
return `
CREATE TABLE IF NOT EXISTS poems (
id ${pk()},
author TEXT NOT NULL,
title TEXT NOT NULL,
source TEXT,
year INTEGER,
created_at TEXT
);
CREATE TABLE IF NOT EXISTS quotes (
id ${pk()},
poem_id INTEGER NOT NULL REFERENCES poems(id) ON DELETE CASCADE,
text TEXT NOT NULL,
tags_json TEXT NOT NULL DEFAULT '[]',
is_user_submitted ${boolType()} NOT NULL DEFAULT ${boolDefault()},
created_at TEXT
);
CREATE TABLE IF NOT EXISTS favorites (
id ${pk()},
quote_id INTEGER NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
visitor_id TEXT NOT NULL,
created_at TEXT,
UNIQUE (quote_id, visitor_id)
);
CREATE INDEX IF NOT EXISTS verse_quote_quotes_poem_id_idx ON quotes(poem_id);
CREATE INDEX IF NOT EXISTS verse_quote_favorites_quote_id_idx ON favorites(quote_id);
CREATE INDEX IF NOT EXISTS verse_quote_favorites_visitor_id_idx ON favorites(visitor_id);
`;
},
},
],
{ table: "verse_quote_migrations" },
);
/**
* Seeds the curated poem/quote set on first boot only. Runs after the table
* migration so it works the same against any DATABASE_URL (Docker Postgres
* here, but also Neon/Supabase/local SQLite) — the seed isn't tied to the
* Docker container's initdb hook.
*/
async function seedIfEmpty(): Promise<void> {
const existing = await db().select({ id: schema.poems.id }).from(schema.poems).limit(1);
if (existing.length > 0) return;
for (const poem of SEED_POEMS) {
const inserted = await db()
.insert(schema.poems)
.values({
author: poem.author,
title: poem.title,
source: poem.source,
year: poem.year,
})
.returning({ id: schema.poems.id });
const poemId = inserted[0].id;
for (const quote of poem.quotes) {
await db().insert(schema.quotes).values({
poem_id: poemId,
text: quote.text,
tags_json: JSON.stringify(quote.tags),
is_user_submitted: false,
});
}
}
console.log(`[db] Seeded ${SEED_POEMS.length} poems.`);
}
export default async (nitroApp: any): Promise<void> => {
await runVerseQuoteMigrations(nitroApp);
try {
const summary = await ensureAdditiveColumns({
db: getDbExec(),
tables: schemaTables,
});
if (summary.errors.length > 0) {
console.warn(
"[db] ensureAdditiveColumns completed with errors:",
summary.errors,
);
}
} catch (err) {
console.warn(
"[db] ensureAdditiveColumns failed (non-fatal):",
err instanceof Error ? err.message : err,
);
}
try {
await seedIfEmpty();
} catch (err) {
console.warn(
"[db] seedIfEmpty failed (non-fatal):",
err instanceof Error ? err.message : err,
);
}
};