Build Verse: a poem-quote app on the agent-native chat template
CI / build (push) Successful in 4m26s
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:
@@ -0,0 +1,12 @@
|
||||
import { createGetDb } from "@agent-native/core/db";
|
||||
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
export const getDb = createGetDb(schema);
|
||||
|
||||
// Convenience: callable as db().select()...
|
||||
export function db() {
|
||||
return getDb();
|
||||
}
|
||||
|
||||
export { schema };
|
||||
@@ -0,0 +1,35 @@
|
||||
import { table, text, integer } from "@agent-native/core/db/schema";
|
||||
|
||||
// A poem a quote is drawn from. Kept minimal — this app curates excerpts,
|
||||
// not full poem texts.
|
||||
export const poems = table("poems", {
|
||||
id: integer("id").primaryKey(),
|
||||
author: text("author").notNull(),
|
||||
title: text("title").notNull(),
|
||||
source: text("source"),
|
||||
year: integer("year"),
|
||||
created_at: text("created_at").$defaultFn(() => new Date().toISOString()),
|
||||
});
|
||||
|
||||
// tags_json is a JSON-encoded string array (e.g. '["hope","nature"]").
|
||||
// Dialect-agnostic schema helpers have no array type, so tags are
|
||||
// JSON-encoded text — the same convention other first-party templates use.
|
||||
export const quotes = table("quotes", {
|
||||
id: integer("id").primaryKey(),
|
||||
poem_id: integer("poem_id").notNull(),
|
||||
text: text("text").notNull(),
|
||||
tags_json: text("tags_json").notNull().default("[]"),
|
||||
is_user_submitted: integer("is_user_submitted", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
created_at: text("created_at").$defaultFn(() => new Date().toISOString()),
|
||||
});
|
||||
|
||||
// visitor_id is a client-generated anonymous id (localStorage), not a real
|
||||
// user account — this app has no login requirement.
|
||||
export const favorites = table("favorites", {
|
||||
id: integer("id").primaryKey(),
|
||||
quote_id: integer("quote_id").notNull(),
|
||||
visitor_id: text("visitor_id").notNull(),
|
||||
created_at: text("created_at").$defaultFn(() => new Date().toISOString()),
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
// Public-domain poem excerpts used to seed a fresh database. Shared by the
|
||||
// startup migration plugin so seeding works the same way regardless of which
|
||||
// SQL provider (Docker Postgres, Neon, Supabase, local SQLite, ...) is
|
||||
// behind DATABASE_URL.
|
||||
|
||||
export type SeedQuote = {
|
||||
text: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type SeedPoem = {
|
||||
author: string;
|
||||
title: string;
|
||||
source: string | null;
|
||||
year: number | null;
|
||||
quotes: SeedQuote[];
|
||||
};
|
||||
|
||||
export const SEED_POEMS: SeedPoem[] = [
|
||||
{
|
||||
author: "Emily Dickinson",
|
||||
title: "“Hope” is the thing with feathers",
|
||||
source: "Poems (1891)",
|
||||
year: 1861,
|
||||
quotes: [
|
||||
{
|
||||
text: "“Hope” is the thing with feathers — that perches in the soul — and sings the tune without the words — and never stops — at all —",
|
||||
tags: ["hope", "resilience"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Robert Frost",
|
||||
title: "The Road Not Taken",
|
||||
source: "Mountain Interval",
|
||||
year: 1916,
|
||||
quotes: [
|
||||
{
|
||||
text: "Two roads diverged in a wood, and I— I took the one less traveled by, and that has made all the difference.",
|
||||
tags: ["choice", "life", "journey"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Walt Whitman",
|
||||
title: "Song of Myself",
|
||||
source: "Leaves of Grass",
|
||||
year: 1855,
|
||||
quotes: [
|
||||
{ text: "I contain multitudes.", tags: ["identity", "self"] },
|
||||
{
|
||||
text: "Do I contradict myself? Very well then I contradict myself, (I am large, I contain multitudes.)",
|
||||
tags: ["identity", "self"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "William Wordsworth",
|
||||
title: "I Wandered Lonely as a Cloud",
|
||||
source: "Poems in Two Volumes",
|
||||
year: 1807,
|
||||
quotes: [
|
||||
{
|
||||
text: "For oft, when on my couch I lie in vacant or in pensive mood, they flash upon that inward eye which is the bliss of solitude.",
|
||||
tags: ["memory", "solitude", "nature"],
|
||||
},
|
||||
{
|
||||
text: "I wandered lonely as a cloud that floats on high o'er vales and hills, when all at once I saw a crowd, a host, of golden daffodils.",
|
||||
tags: ["nature", "solitude"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "William Shakespeare",
|
||||
title: "Sonnet 18",
|
||||
source: "Shakespeare's Sonnets",
|
||||
year: 1609,
|
||||
quotes: [
|
||||
{
|
||||
text: "Shall I compare thee to a summer's day? Thou art more lovely and more temperate.",
|
||||
tags: ["love", "beauty"],
|
||||
},
|
||||
{
|
||||
text: "So long as men can breathe or eyes can see, so long lives this, and this gives life to thee.",
|
||||
tags: ["love", "immortality"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "John Keats",
|
||||
title: "Endymion",
|
||||
source: null,
|
||||
year: 1818,
|
||||
quotes: [
|
||||
{
|
||||
text: "A thing of beauty is a joy for ever: its loveliness increases; it will never pass into nothingness.",
|
||||
tags: ["beauty", "joy"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "William Butler Yeats",
|
||||
title: "The Second Coming",
|
||||
source: "Michael Robartes and the Dancer",
|
||||
year: 1920,
|
||||
quotes: [
|
||||
{
|
||||
text: "Turning and turning in the widening gyre the falcon cannot hear the falconer; things fall apart; the centre cannot hold.",
|
||||
tags: ["chaos", "change"],
|
||||
},
|
||||
{
|
||||
text: "The best lack all conviction, while the worst are full of passionate intensity.",
|
||||
tags: ["conviction", "society"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Percy Bysshe Shelley",
|
||||
title: "Ozymandias",
|
||||
source: null,
|
||||
year: 1818,
|
||||
quotes: [
|
||||
{
|
||||
text: "My name is Ozymandias, king of kings: look on my works, ye Mighty, and despair! Nothing beside remains.",
|
||||
tags: ["time", "pride", "impermanence"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Alfred, Lord Tennyson",
|
||||
title: "Ulysses",
|
||||
source: null,
|
||||
year: 1842,
|
||||
quotes: [
|
||||
{
|
||||
text: "Though much is taken, much abides; and though we are not now that strength which in old days moved earth and heaven... one equal temper of heroic hearts... strong in will to strive, to seek, to find, and not to yield.",
|
||||
tags: ["perseverance", "age", "courage"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Edgar Allan Poe",
|
||||
title: "A Dream Within a Dream",
|
||||
source: null,
|
||||
year: 1849,
|
||||
quotes: [
|
||||
{
|
||||
text: "Is all that we see or seem but a dream within a dream?",
|
||||
tags: ["dreams", "existence"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "William Blake",
|
||||
title: "Auguries of Innocence",
|
||||
source: null,
|
||||
year: 1863,
|
||||
quotes: [
|
||||
{
|
||||
text: "To see a World in a Grain of Sand, and a Heaven in a Wild Flower, hold Infinity in the palm of your hand, and Eternity in an hour.",
|
||||
tags: ["wonder", "nature", "infinity"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Christina Rossetti",
|
||||
title: "Remember",
|
||||
source: "Goblin Market and Other Poems",
|
||||
year: 1862,
|
||||
quotes: [
|
||||
{
|
||||
text: "Better by far you should forget and smile than that you should remember and be sad.",
|
||||
tags: ["memory", "love", "loss"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Robert Frost",
|
||||
title: "Stopping by Woods on a Snowy Evening",
|
||||
source: "New Hampshire",
|
||||
year: 1923,
|
||||
quotes: [
|
||||
{
|
||||
text: "The woods are lovely, dark and deep, but I have promises to keep, and miles to go before I sleep.",
|
||||
tags: ["duty", "rest", "nature"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Emily Dickinson",
|
||||
title: "Because I could not stop for Death",
|
||||
source: "Poems (1890)",
|
||||
year: 1863,
|
||||
quotes: [
|
||||
{
|
||||
text: "Because I could not stop for Death, he kindly stopped for me; the carriage held but just ourselves and Immortality.",
|
||||
tags: ["death", "time"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Walt Whitman",
|
||||
title: "O Captain! My Captain!",
|
||||
source: "Sequel to Drums-Taps",
|
||||
year: 1865,
|
||||
quotes: [
|
||||
{
|
||||
text: "O Captain! my Captain! our fearful trip is done, the ship has weather'd every rack, the prize we sought is won.",
|
||||
tags: ["triumph", "loss", "leadership"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Rumi",
|
||||
title: "The Guest House",
|
||||
source: "The Essential Rumi (translation)",
|
||||
year: 1207,
|
||||
quotes: [
|
||||
{
|
||||
text: "This being human is a guest house. Every morning a new arrival... Welcome and entertain them all!",
|
||||
tags: ["acceptance", "emotion", "mindfulness"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Sara Teasdale",
|
||||
title: "Barter",
|
||||
source: "Rivers to the Sea",
|
||||
year: 1915,
|
||||
quotes: [
|
||||
{
|
||||
text: "Life has loveliness to sell, all beautiful and splendid things, blue waves whitened on a cliff, soaring fire that sways and sings.",
|
||||
tags: ["beauty", "life", "joy"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Elizabeth Barrett Browning",
|
||||
title: "Sonnet 43 (How Do I Love Thee?)",
|
||||
source: "Sonnets from the Portuguese",
|
||||
year: 1850,
|
||||
quotes: [
|
||||
{
|
||||
text: "How do I love thee? Let me count the ways. I love thee to the depth and breadth and height my soul can reach.",
|
||||
tags: ["love"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Langston Hughes",
|
||||
title: "Dreams",
|
||||
source: "The Weary Blues",
|
||||
year: 1926,
|
||||
quotes: [
|
||||
{
|
||||
text: "Hold fast to dreams, for if dreams die, life is a broken-winged bird that cannot fly.",
|
||||
tags: ["dreams", "hope"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Henry Wadsworth Longfellow",
|
||||
title: "A Psalm of Life",
|
||||
source: "Voices of the Night",
|
||||
year: 1838,
|
||||
quotes: [
|
||||
{
|
||||
text: "Life is real! Life is earnest! And the grave is not its goal... Let us, then, be up and doing, with a heart for any fate.",
|
||||
tags: ["life", "purpose", "courage"],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,85 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { db, schema } from "../db/index.js";
|
||||
|
||||
export type PoemInfo = {
|
||||
id: number;
|
||||
title: string;
|
||||
author: string;
|
||||
source: string | null;
|
||||
year: number | null;
|
||||
};
|
||||
|
||||
export type QuoteWithPoem = {
|
||||
id: number;
|
||||
text: string;
|
||||
tags: string[];
|
||||
isUserSubmitted: boolean;
|
||||
createdAt: string | null;
|
||||
poem: PoemInfo;
|
||||
favoriteCount: number;
|
||||
isFavorited: boolean;
|
||||
};
|
||||
|
||||
export function parseTags(tagsJson: string): string[] {
|
||||
try {
|
||||
const value = JSON.parse(tagsJson);
|
||||
return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
type RawRow = {
|
||||
quote: typeof schema.quotes.$inferSelect;
|
||||
poem: typeof schema.poems.$inferSelect;
|
||||
};
|
||||
|
||||
/**
|
||||
* Joins quote rows with their poem, favorite counts, and (when visitorId is
|
||||
* given) whether that visitor has favorited each quote. Shared by every
|
||||
* action that returns quotes so the shape stays identical across list,
|
||||
* random, favorites, and create.
|
||||
*/
|
||||
export async function hydrateQuotes(
|
||||
rows: RawRow[],
|
||||
visitorId?: string,
|
||||
): Promise<QuoteWithPoem[]> {
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const quoteIds = rows.map((r) => r.quote.id);
|
||||
const allFavorites = await db().select().from(schema.favorites);
|
||||
const countByQuoteId = new Map<number, number>();
|
||||
const favoritedByVisitor = new Set<number>();
|
||||
for (const fav of allFavorites) {
|
||||
if (!quoteIds.includes(fav.quote_id)) continue;
|
||||
countByQuoteId.set(fav.quote_id, (countByQuoteId.get(fav.quote_id) ?? 0) + 1);
|
||||
if (visitorId && fav.visitor_id === visitorId) {
|
||||
favoritedByVisitor.add(fav.quote_id);
|
||||
}
|
||||
}
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.quote.id,
|
||||
text: row.quote.text,
|
||||
tags: parseTags(row.quote.tags_json),
|
||||
isUserSubmitted: row.quote.is_user_submitted,
|
||||
createdAt: row.quote.created_at ?? null,
|
||||
poem: {
|
||||
id: row.poem.id,
|
||||
title: row.poem.title,
|
||||
author: row.poem.author,
|
||||
source: row.poem.source ?? null,
|
||||
year: row.poem.year ?? null,
|
||||
},
|
||||
favoriteCount: countByQuoteId.get(row.quote.id) ?? 0,
|
||||
isFavorited: favoritedByVisitor.has(row.quote.id),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchAllQuoteRows(): Promise<RawRow[]> {
|
||||
return db()
|
||||
.select({ quote: schema.quotes, poem: schema.poems })
|
||||
.from(schema.quotes)
|
||||
.innerJoin(schema.poems, eq(schema.quotes.poem_id, schema.poems.id));
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* OpenTelemetry setup for verse-quote-app: exports traces, metrics, and logs
|
||||
* over OTLP/HTTP to the shared Grafana LGTM collector on this VM
|
||||
* (http://localhost:4318). Manual instrumentation rather than
|
||||
* auto-instrumentation-node — this app's ESM/Nitro entrypoint doesn't give
|
||||
* us a require-before-everything-else hook to patch http/pg at, so we
|
||||
* instrument the one seam that matters for this app: action calls.
|
||||
*/
|
||||
import { trace, metrics, diag, DiagConsoleLogger, DiagLogLevel, type Span } from "@opentelemetry/api";
|
||||
import { logs, SeverityNumber } from "@opentelemetry/api-logs";
|
||||
import { resourceFromAttributes } from "@opentelemetry/resources";
|
||||
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
||||
import {
|
||||
BasicTracerProvider,
|
||||
BatchSpanProcessor,
|
||||
} from "@opentelemetry/sdk-trace-base";
|
||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
||||
import {
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
||||
import {
|
||||
LoggerProvider,
|
||||
BatchLogRecordProcessor,
|
||||
} from "@opentelemetry/sdk-logs";
|
||||
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
|
||||
|
||||
const SERVICE_NAME = process.env.OTEL_SERVICE_NAME || "verse-quote-app";
|
||||
const OTLP_ENDPOINT =
|
||||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318";
|
||||
|
||||
let started = false;
|
||||
|
||||
export function startOtel(): void {
|
||||
if (started) return;
|
||||
started = true;
|
||||
|
||||
if (process.env.OTEL_DEBUG) {
|
||||
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG);
|
||||
}
|
||||
|
||||
const resource = resourceFromAttributes({
|
||||
[ATTR_SERVICE_NAME]: SERVICE_NAME,
|
||||
});
|
||||
|
||||
const tracerProvider = new BasicTracerProvider({
|
||||
resource,
|
||||
spanProcessors: [
|
||||
new BatchSpanProcessor(
|
||||
new OTLPTraceExporter({ url: `${OTLP_ENDPOINT}/v1/traces` }),
|
||||
),
|
||||
],
|
||||
});
|
||||
trace.setGlobalTracerProvider(tracerProvider);
|
||||
|
||||
const meterProvider = new MeterProvider({
|
||||
resource,
|
||||
readers: [
|
||||
new PeriodicExportingMetricReader({
|
||||
exporter: new OTLPMetricExporter({ url: `${OTLP_ENDPOINT}/v1/metrics` }),
|
||||
exportIntervalMillis: 15000,
|
||||
}),
|
||||
],
|
||||
});
|
||||
metrics.setGlobalMeterProvider(meterProvider);
|
||||
|
||||
const loggerProvider = new LoggerProvider({
|
||||
resource,
|
||||
processors: [
|
||||
new BatchLogRecordProcessor({
|
||||
exporter: new OTLPLogExporter({ url: `${OTLP_ENDPOINT}/v1/logs` }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
logs.setGlobalLoggerProvider(loggerProvider);
|
||||
|
||||
const shutdown = () => {
|
||||
Promise.allSettled([
|
||||
tracerProvider.shutdown(),
|
||||
meterProvider.shutdown(),
|
||||
loggerProvider.shutdown(),
|
||||
]).finally(() => process.exit(0));
|
||||
};
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
console.log(`[otel] started, exporting to ${OTLP_ENDPOINT} as service "${SERVICE_NAME}"`);
|
||||
}
|
||||
|
||||
// Must run before getMeter()/createCounter() below: unlike the tracer and
|
||||
// logger proxies (which re-resolve their delegate on every call), the metrics
|
||||
// API's Counter is bound to whatever provider is active at createCounter()
|
||||
// time — calling startOtel() after creating counters would permanently bind
|
||||
// them to the no-op backend and silently drop every metric.
|
||||
startOtel();
|
||||
|
||||
const tracer = trace.getTracer(SERVICE_NAME);
|
||||
const meter = metrics.getMeter(SERVICE_NAME);
|
||||
const otelLogger = logs.getLogger(SERVICE_NAME);
|
||||
|
||||
export const actionCallCounter = meter.createCounter("verse_quote_action_calls_total", {
|
||||
description: "Number of verse-quote-app action invocations, by action name and outcome",
|
||||
});
|
||||
|
||||
export const quoteFavoriteCounter = meter.createCounter("verse_quote_favorites_total", {
|
||||
description: "Number of favorite/unfavorite toggles on quotes",
|
||||
});
|
||||
|
||||
/**
|
||||
* Wrap an action's run() body in a span + call counter + structured log.
|
||||
* Records success/error outcome and re-throws so defineAction's own error
|
||||
* handling is unaffected.
|
||||
*/
|
||||
export async function withActionSpan<T>(
|
||||
actionName: string,
|
||||
attributes: Record<string, string | number | boolean | undefined>,
|
||||
fn: (span: Span) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return tracer.startActiveSpan(`action.${actionName}`, async (span) => {
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
if (value !== undefined) span.setAttribute(key, value);
|
||||
}
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await fn(span);
|
||||
actionCallCounter.add(1, { action: actionName, outcome: "success" });
|
||||
otelLogger.emit({
|
||||
severityNumber: SeverityNumber.INFO,
|
||||
severityText: "INFO",
|
||||
body: `action ${actionName} succeeded in ${Date.now() - start}ms`,
|
||||
attributes: { action: actionName, ...attributes },
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
actionCallCounter.add(1, { action: actionName, outcome: "error" });
|
||||
span.recordException(err instanceof Error ? err : new Error(String(err)));
|
||||
otelLogger.emit({
|
||||
severityNumber: SeverityNumber.ERROR,
|
||||
severityText: "ERROR",
|
||||
body: `action ${actionName} failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
attributes: { action: actionName, ...attributes },
|
||||
});
|
||||
throw err;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user