Skip to main content
Treat prompts like code: changing one is a deploy. vibestrap stores prompts in two tables — prompt (the slug + latest pointer) and prompt_version (immutable history). You publish a new version by inserting a row, never by mutating one. Rollback is publishing the old text again as a new version. Auditable, race-free, boring.

Prerequisites

  • A migrated database (pnpm db:push) so prompt and prompt_version exist.
  • A signed-in admin user — only the adminActionClient should publish new versions.
  • Read src/db/ai.schema.ts for the column-level truth.

The two tables

// src/db/ai.schema.ts
export const prompt = pgTable('prompt', {
  id: text('id').primaryKey(),               // nanoid prefixed: 'pmt_...'
  slug: text('slug').notNull(),              // unique — the public name
  name: text('name').notNull(),
  description: text('description').notNull().default(''),
  latestVersionId: text('latest_version_id'),// pointer to current published version
  // ...timestamps, createdBy
});

export const promptVersion = pgTable('prompt_version', {
  id: text('id').primaryKey(),
  promptId: text('prompt_id').notNull().references(() => prompt.id),
  version: integer('version').notNull(),     // 1, 2, 3, monotonic per prompt
  template: text('template').notNull(),      // body with {{variable}} placeholders
  variables: jsonb('variables').$type<Record<string, { description?: string; required?: boolean }>>(),
  note: text('note').notNull().default(''),
  // ...createdAt, createdBy
});
A unique index on (promptId, version) keeps the version sequence honest. The latestVersionId pointer is denormalized for one-query lookups.

Step-by-step: register a new prompt

Wrap the three writes in a single transaction so a partial state never lands.
import { db } from '@/db';
import { prompt, promptVersion } from '@/db/schema';
import { newPrefixedId } from '@/lib/id';

await db.transaction(async (tx) => {
  const promptId = newPrefixedId('pmt');
  const versionId = newPrefixedId('pv');

  await tx.insert(prompt).values({
    id: promptId,
    slug: 'summarize-article',
    name: 'Summarize an article',
  });

  await tx.insert(promptVersion).values({
    id: versionId,
    promptId,
    version: 1,
    template: 'Summarize the following article in 3 bullets:\n\n{{article}}',
    variables: { article: { description: 'Raw article text', required: true } },
  });

  await tx.update(prompt).set({ latestVersionId: versionId }).where(eq(prompt.id, promptId));
});
To publish v2, insert a new prompt_version with version: 2 and update latestVersionId — same pattern. Old versions remain reachable through the unique (promptId, version) index.

Rendering on the client

usePrompt(slug) fetches the latest version and exposes render(vars):
const { prompt, render } = usePrompt('summarize-article');
const finalText = prompt ? render({ article: input }) : null;
// pass finalText into useGeneration → server → AI provider
The substitution is regex-based ({{name}} → value). Missing variables become empty strings — there’s no client-side “required” enforcement, you check on the server before sending the call.

Rendering on the server

Don’t import the hook. Read directly:
import { db } from '@/db';
import { prompt, promptVersion } from '@/db/schema';
import { eq } from 'drizzle-orm';

const row = await db
  .select({
    template: promptVersion.template,
    promptId: prompt.id,
    promptVersionId: promptVersion.id,
  })
  .from(prompt)
  .innerJoin(promptVersion, eq(promptVersion.id, prompt.latestVersionId))
  .where(eq(prompt.slug, 'summarize-article'))
  .then((r) => r[0]);

const text = row.template.replace(/{{\s*(\w+)\s*}}/g, (_, k) => values[k] ?? '');
Pass promptId and promptVersionId into chat({ ... }, { promptId, promptVersionId, userId }) so the ai_call row tracks which prompt version drove the call.

Rollback

There is no UPDATE. Rollback = “publish the old text as a new version”:
  1. Fetch promptVersion for the version you want to restore.
  2. Insert a new row with version: max+1, same template, optional note like 'rollback to v3'.
  3. Update latestVersionId to the new row.
History stays linear, audit trail intact, no surprises if a request was mid-flight.

Verify it works

  1. Insert a prompt via the steps above.
  2. psql and run SELECT slug, latest_version_id FROM prompt WHERE slug = 'summarize-article';latest_version_id should be set.
  3. From a client component, render usePrompt('summarize-article').render({ article: 'hello' }) — you should get the substituted string.
  4. After making an AI call with { promptId, promptVersionId }, check ai_call: the prompt_id and prompt_version_id columns should be populated.

Common pitfalls

  • Forgetting latestVersionId after the insert. The version is created but usePrompt returns null because the pointer wasn’t moved. Always update inside the same transaction.
  • Mutating an existing prompt_version row. Don’t. The unique index doesn’t stop you, but doing so silently rewrites history and makes ai_call.promptVersionId references lie. Insert a new version.
  • Missing required variables on the server. render happily produces empty substitutions. Validate Object.entries(variables).filter(([_, s]) => s.required) before calling chat.
  • Slug collisions. prompt.slug has a unique index — your insert will throw. Pick a namespaced slug like summarize-article-v2 rather than reusing.
  • Versioning prompts you load from disk. If your template is in a .txt file read at build time, the database doesn’t know about it. Pick one source of truth.

Official docs