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) sopromptandprompt_versionexist. - A signed-in admin user — only the
adminActionClientshould publish new versions. - Read
src/db/ai.schema.tsfor the column-level truth.
The two tables
(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.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):
{{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: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”:- Fetch
promptVersionfor the version you want to restore. - Insert a new row with
version: max+1, sametemplate, optional note like'rollback to v3'. - Update
latestVersionIdto the new row.
Verify it works
- Insert a prompt via the steps above.
psqland runSELECT slug, latest_version_id FROM prompt WHERE slug = 'summarize-article';—latest_version_idshould be set.- From a client component, render
usePrompt('summarize-article').render({ article: 'hello' })— you should get the substituted string. - After making an AI call with
{ promptId, promptVersionId }, checkai_call: theprompt_idandprompt_version_idcolumns should be populated.
Common pitfalls
- Forgetting
latestVersionIdafter the insert. The version is created butusePromptreturnsnullbecause the pointer wasn’t moved. Always update inside the same transaction. - Mutating an existing
prompt_versionrow. Don’t. The unique index doesn’t stop you, but doing so silently rewrites history and makesai_call.promptVersionIdreferences lie. Insert a new version. - Missing required variables on the server.
renderhappily produces empty substitutions. ValidateObject.entries(variables).filter(([_, s]) => s.required)before callingchat. - Slug collisions.
prompt.slughas a unique index — your insert will throw. Pick a namespaced slug likesummarize-article-v2rather than reusing. - Versioning prompts you load from disk. If your template is in a
.txtfile read at build time, the database doesn’t know about it. Pick one source of truth.
Official docs
- Drizzle ORM: orm.drizzle.team
- Postgres unique indexes: postgresql.org/docs/current/indexes-unique.html
- Source:
src/db/ai.schema.ts,src/ai/hooks/use-prompt.ts