PromptVault/SPECS.md
2026-10-26 15:12:16 -05:00

20 KiB

PromptVault — Application Specification

Status: COMPLETE (source of truth for this application) Last updated: 2026-10-26

1. Overview

PromptVault is a local-first prompt library for LLM workflows. It lets a developer store, organize, search, and reuse reusable prompt templates, with {{variable}} placeholders that can be filled in and rendered on demand. Everything runs locally: PHP + SQLite on the backend, Vue 3 SPA on the front end. No accounts, no cloud, no external LLM calls.

1.1 Goals

  • CRUD for prompts with name, description, body, and tags.
  • Extract {{variables}} from a prompt body; render the prompt with provided values; strict about missing values.
  • Search (full-text-ish substring), filter by tag and by favorite, sort by a whitelisted set of fields, pagination.
  • Track per-prompt usage (usage_count, last_used_at) incremented whenever a prompt is rendered.
  • Duplicate, favorite-toggle, and delete prompts.
  • Fast, keyboard-friendly single-page UI.

1.2 Non-goals (v1)

  • Authentication / multi-user support (single-user local tool).
  • Calling LLM providers; rendering is purely client-side text interpolation.
  • Import/export (CSV/JSON), sharing, comments, versioning.
  • Soft deletes, audit logs, i18n.

2. Tech stack (decided)

Layer Choice Notes
Language PHP 8.4 (min 8.3)
Framework Laravel (current stable, 12.x/13.x skeleton) Slim skeleton, bootstrap/app.php wiring
ORM Eloquent
Database SQLite 3 (pdo_sqlite), database/database.sqlite Laravel's default
Frontend Vue 3.5 (<script setup>) + Inertia.js 3 (@inertiajs/vue3) SPA served by Laravel
Bundler Vite 8 + laravel-vite-plugin
Styling Tailwind CSS 4 (@tailwindcss/vite) No component libraries
PHP tests PHPUnit 12 (shipped with skeleton) + Xdebug for coverage RefreshDatabase
JS tests Vitest 5 + @vue/test-utils + happy-dom v8 coverage provider
HTTP client (JS) axios (skeleton default, used by Inertia)

All versions are "latest stable at scaffold time"; the exact resolved versions live in composer.json / package.json (vendor & node_modules excluded from coverage, per guidelines).

3. Architecture

Principles: SOLID, KISS, CQRS-lite (separate command/write models from query/read models), Law of Demeter (each object talks only to its immediate neighbors: Controller → Service/Request → Model; Vue component → its own props → Inertia).

3.1 CQRS-lite layout

Writes go through command DTOs handled by PromptService; reads go through query DTOs handled by PromptCatalog. Single-entity actions (delete, duplicate, favorite, usage) take the model directly — KISS over ceremony.

app/
  Actions/Prompts/
    StorePrompt.php          # command DTO: name, description, body, tags[]
    UpdatePrompt.php         # command DTO: same shape, applied to a Prompt
    ListPrompts.php          # query DTO: search, tag, favorites, sort, direction
  Services/
    PromptService.php        # store, update, delete, duplicate, toggleFavorite, recordUsage
    PromptCatalog.php        # list(ListPrompts) -> LengthAwarePaginator; get(id) -> Prompt
    PromptRenderer.php       # pure: extractVariables(), render() (strict)
  Exceptions/
    PromptRenderException.php# carries missingVariables[]
  Http/
    Controllers/PromptController.php  # thin: bind request -> DTO -> service/catalog
    Requests/
      StorePromptRequest.php
      UpdatePromptRequest.php
  Models/
    Prompt.php               # belongsToMany Tag
    Tag.php                  # belongsToMany Prompt
    User.php                 # skeleton default, unused (kept for parity)
database/
  migrations/…_create_tags_table.php
  migrations/…_create_prompts_table.php   # prompts + prompt_tag pivot
  seeders/PromptSeeder.php   # demo tags + 4 sample prompts
  seeders/DatabaseSeeder.php # calls PromptSeeder
resources/js/
  app.js                     # entry wiring (excluded from coverage: wiring only)
  Layouts/AppLayout.vue      # shell, nav
  Pages/Prompts/Index.vue    # table + search/filter/sort + pagination + row actions
  Pages/Prompts/Create.vue   # PromptForm -> POST
  Pages/Prompts/Edit.vue     # PromptForm -> PUT
  Pages/Prompts/Show.vue     # details, RenderPreview, usage stats
  Components/
    PromptForm.vue           # shared create/edit form
    RenderPreview.vue        # variable inputs + live rendered preview
    ConfirmDialog.vue        # confirm-before-danger
    TagPill.vue              # tag chip (clickable in filter contexts)
tests/
  Unit/                      # renderer, models
  Feature/                   # every route, happy + sad paths
  js/                        # Vitest: components + pages (Inertia mocked)

3.2 Dependency direction

routes/web.php → PromptController → (StorePromptRequest | DTO) → PromptService / PromptCatalog → Eloquent models → SQLite. No model calls a service; no controller touches the DB directly; the renderer is a pure function with zero framework imports (SRP/DIP-friendly, trivially unit-testable).

4. Data model

CREATE TABLE tags (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL UNIQUE,           -- max 40, normalized lowercase/trimmed
  created_at TEXT, updated_at TEXT
);

CREATE TABLE prompts (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,                  -- max 120
  description TEXT,                    -- max 500, nullable
  body TEXT NOT NULL,                  -- prompt template, may contain {{vars}}
  is_favorite INTEGER NOT NULL DEFAULT 0,
  usage_count INTEGER NOT NULL DEFAULT 0,
  last_used_at TEXT,                   -- nullable timestamp
  created_at TEXT, updated_at TEXT
);

CREATE TABLE prompt_tag (
  prompt_id INTEGER NOT NULL REFERENCES prompts(id) ON DELETE CASCADE,
  tag_id    INTEGER NOT NULL REFERENCES tags(id)    ON DELETE CASCADE,
  PRIMARY KEY (prompt_id, tag_id)
);

Eloquent casts: is_favorite => boolean, usage_count => integer, last_used_at => datetime. tags() is belongsToMany with alphabetical ordering.

5. HTTP surface

All pages are Inertia (HTML); /use is JSON. No auth middleware.

Method Route Page/JSON Behavior
GET / redirect 302 → /prompts
GET /prompts Index List with search, tag, favorites, sort, direction, page query params
GET /prompts/create Create Empty form
POST /prompts redirect Validate → PromptService::store → redirect to new prompt
GET /prompts/{prompt} Show Prompt + extracted variables; 404 if missing
GET /prompts/{prompt}/edit Edit Form pre-filled
PUT /prompts/{prompt} redirect Validate → update → redirect back to show
DELETE /prompts/{prompt} redirect Delete → redirect to index
POST /prompts/{prompt}/duplicate redirect Copy → redirect to new prompt
POST /prompts/{prompt}/toggle-favorite redirect Flip is_favorite → redirect back
POST /prompts/{prompt}/use JSON Body {"values":{"var":"…"}} → {"rendered":"…"}; on missing vars → 422 {"message":"Missing variables","missing":[…]}; on success increments usage

5.1 Query param contract (GET /prompts)

  • search — substring match (case-insensitive) across name, description, body.
  • tag — tag name; unknown tag ⇒ empty result set (no error).
  • favorites — "1" ⇒ only favorites.
  • sort — whitelist: created_at (default), name, usage_count, last_used_at, updated_at. Anything else ⇒ created_at.
  • direction — asc | desc (default desc; name sorts asc by default).
  • page — 1-based; 10 per page.
  • last_used_at with asc/desc keeps NULLs last via orderByNulls-equivalent (whereNotNull-aware ordering).

5.2 Validation rules

Field Store Update
name required, string, max:120 required, string, max:120
description nullable, string, max:500 nullable, string, max:500
body required, string required, string
tags nullable, array; each: string, max:40 nullable, array; each: string, max:40

tags.* entries: non-strings are rejected (validation); empty / whitespace-only strings are accepted and stripped during normalization (Laravel's string rule would wrongly reject them — see decision log #14).

Tag normalization: trim → strip empty → lowercase → unique → create-or-reuse by name → sync.

6. Prompt renderer semantics (decided)

  • Variable syntax: {{ name }} — regex /\{\{\s*([A-Za-z0-9_-]+)\s*\}\}/. Anything else ({{}}, {{a b}}, {{a!b}}) is literal text, not a variable.
  • extractVariables(string $body): string[] — unique, first-occurrence order.
  • render(string $body, array $values): string — replaces every occurrence. Strict mode: if any variable lacks a string value (missing, or non-string value), throws PromptRenderException with the full ordered list of missing variable names. Unknown/extra values in $values are ignored.
  • Values are inserted verbatim (no escaping; this is plain-text templating, not HTML generation).

7. Service behavior (decided)

  • store(StorePrompt): Prompt — insert + sync tags.
  • update(UpdatePrompt, Prompt): Prompt — set fields + sync tags; usage stats untouched.
  • delete(Prompt): void — hard delete (pivot cascades).
  • duplicate(Prompt): Prompt — new row: name = "{name} (copy)" (appending (copy) repeatedly until unique, e.g. "X (copy) (copy)"), same description/body/tags, is_favorite = false, usage_count = 0, last_used_at = null.
  • toggleFavorite(Prompt): Prompt — flip boolean.
  • recordUsage(Prompt): Prompt — usage_count += 1, last_used_at = now.
  • PromptCatalog::list(ListPrompts) — applies §5.1, eager-loads tags, paginates 10.
  • Single-prompt reads (show/edit/use) use route model binding + loadMissing('tags'); no separate PromptCatalog::get() (decision #15).

8. Frontend decisions

  • Inertia 2 + Vue 3 <script setup>; no Pinia (prop-driven pages, KISS).
  • AppLayout: top bar with brand "PromptVault", nav (Prompts, + New Prompt), max-width container.
  • Index: search input (debounced, updates ?search), tag dropdown (from tags prop), favorites toggle, sort/direction select, table (Name / Tags / Usage / Last used / Favorite star), row actions: Show, Duplicate, Favorite, Delete (ConfirmDialog). Pagination prev/next.
  • Show: metadata, tags, body in <pre>, variable list, RenderPreview (one input per variable + live preview + "Copy rendered" button + "Use prompt" button that POSTs values and shows the rendered result and bumps usage), danger zone (Duplicate / Delete).
  • Create/Edit: shared PromptForm (name, description, body, tag input with comma separation + suggestion chips), server error display, submit via Inertia useForm.
  • RenderPreview computes the preview client-side using the same regex rules as the server (documented as a mirrored contract).
  • Styling: Tailwind utility classes only; dark-agnostic neutral palette; no external fonts/icons (unicode glyphs only, e.g. ★ ✎ 🗑 — keep it dependency-free).

9. Testing strategy (decided)

Target: ≥ 95% line coverage of application code with:

  • PHP: app/** (PHPUnit 12 + Xdebug).
  • JS: resources/js/** (Vitest 5 + v8 provider), excluding app.js (framework wiring with no logic — disclosed exclusion).
  • vendor/ and node_modules/ excluded per guidelines (they never are).

PHP suites (PHPUnit 12; inertiajs/inertia-laravel 3 ships the built-in assertInertia macro, no extra package needed):

  • Unit/PromptRendererTest.php — extraction edge cases (none, repeats, spacing, invalid tokens, order), render success, strict missing, extra values ignored, verbatim insertion.
  • Unit/ListPromptsTest.php — sort/direction normalization + fallbacks.
  • Unit/ModelsTest.php — Prompt/Tag relations, casts, tag ordering, User smoke (skeleton model).
  • Feature/HomeTest.php — GET / → 302 → /prompts.
  • Feature/Prompts/PromptIndexTest.php — default list, search, tag filter (known + unknown), favorites, each sort + direction, last_used_at NULLs-last in both directions, invalid sort/direction fallback, pagination size.
  • Feature/Prompts/PromptStoreUpdateTest.php — create page render (empty form + suggested tags; old-input reflash after failed store), valid store (incl. tag normalization/create/reuse), each invalid case, valid update, invalid update, tag sync on update (add + remove).
  • Feature/Prompts/PromptShowDeleteTest.php — show found/404, edit page, delete/404.
  • Feature/Prompts/PromptActionsTest.php — duplicate (copy name, tag copy, unique-name collision, stats reset), toggle favorite (on↔off), use success (payload rendered, counters updated), use missing variables (422 + missing[]), use non-string value (422), use with no values (validation 422), use 404.

JS suites (tests/js/ — 64 tests across 10 files, all green; Inertia mocked with a shared stub, tests/js/stubs/inertia.js: Link, Head, router, useForm, usePage, plus inertiaMock helpers failNext (rejected submission: seeds form.errors + page.props.errors, fires onError) and deferSubmissions (assert processing === true states); resources/js/app.js entry wiring excluded from coverage, disclosed):

  • lib/format.test.js — empty → em-dash, invalid → raw pass-through, valid → localized format.
  • Components/TagPill.test.js — plain span vs. filterable Inertia link (encoded filter href).
  • Components/ConfirmDialog.test.js — render states, confirm/cancel emits, Escape key handling, listener attach/detach.
  • Components/PromptForm.test.js — field render, tag chip add/remove (Enter/comma/Backspace/blur), duplicate/empty rejection, suggestion chips, server-error display (array + string), POST/PUT payload transform, processing state, onError/clearErrors, cancel link.
  • Components/RenderPreview.test.js — variable input generation, live preview substitution, empty-state, use emit with entered values, busy state, clipboard copy success/failure.
  • Layouts/AppLayout.test.js — brand/nav/footer + slot render.
  • Pages/Prompts/Index.test.js — rows from props, empty state, search/submit form, tag/favorites/sort/direction filters, favorite & duplicate actions, delete confirm + cancel (+ no-target guard), pagination incl. boundaries.
  • Pages/Prompts/Show.test.js — renders prompt + variables, favorite/duplicate /delete flows, copy template (success/failure), "use" flow success, 422 with missing[] list, unreadable error body, network failure.
  • Pages/Prompts/Create.test.js / Pages/Prompts/Edit.test.js — form wiring (titles, labels, hrefs) + submit URLs.

10. Conventions

  • Controllers stay < ~40 lines per action: validate → map to DTO → service → respond. No business logic in controllers, requests, or models.
  • No dd()/dump() in committed code; no TODOs left in code.
  • Migrations are forward-only; seeders are idempotent (delete-then-seed demo data).
  • All new behavior lands in SPECS.md before code (this file is the source of truth).
  • Commits/batches: code written in batches of ≤ 5 files per pass.

11. Decision log

# Date Decision
1 2026-10-24 Build PromptVault (local-first LLM prompt library) as the application.
2 2026-10-24 Stack: Laravel (latest) + SQLite + Vue 3 + Inertia + Vite + Tailwind 4; PHPUnit + Vitest.
3 2026-10-24 CQRS-lite: command/query DTOs + PromptService/PromptCatalog; pure PromptRenderer.
4 2026-10-24 No auth in v1; hard deletes; usage counted on render (/use).
5 2026-10-24 Strict variable rendering; variable charset [A-Za-z0-9_-]+.
6 2026-10-24 ≥ 95% coverage of app/ + resources/js/ (entry wiring files excluded, disclosed).
7 2026-10-24 10 items/page pagination; sort whitelist; tag names normalized lowercase.
8 2026-10-24 Skeleton resolved to Laravel 13 + PHPUnit 12 (skeleton default, kept over Pest) + Vite 8.
9 2026-10-24 Resolved deps: @inertiajs/vue3 3.x, Vue 3.5, Vitest 5, inertiajs/inertia-laravel 3.5. Root view resources/views/app.blade.php uses <inertia:app> / <inertia:head>.
10 2026-10-25 Bug fix: PromptCatalog::list() used non-existent orderByNulls() (500 on ?sort=last_used_at); replaced with orderByRaw('last_used_at IS NULL') + orderBy() so NULLs sort last in either direction (§5.1).
11 2026-10-25 Bug fix: PromptService::duplicate() synced tags via pluck('tags.id','tags.id') (always null → corrupt pivot insert); now pluck('id') (§7).
12 2026-10-25 JS coverage needs @vitest/coverage-v8 (installed as devDependency); npm run test:js / test:js:coverage scripts added.
13 2026-10-25 duplicate() reworked: $model->replicate(['name','description','body']) misused Laravel's $except semantics (those attributes were removed → NOT NULL violation on body); replaced with an explicit Prompt::create() + tag sync (KISS, matches store/update shape; stats reset via column defaults) (§7).
14 2026-10-25 tags.* validation changed from ['string','max:40'] to an is_string closure + max:40: the string rule (via FILTER_VALIDATE_STRING) rejects empty/whitespace-only entries, contradicting the §5.2 contract that such entries are stripped during normalization. Laravel's global TrimStrings + ConvertEmptyStringsToNull middleware rewrite empty/whitespace entries to null before validation, so the closure accepts null (stripped in tagIds); other non-strings are rejected (§5.2).
15 2026-10-25 Removed unused PromptCatalog::get() (single-prompt reads use route model binding + loadMissing('tags') — KISS, no dead API, §7); added create-page tests covering PromptController::create() (empty form + suggested tags; old-input reflash). Only remaining uncovered PHP lines: two defensive preg_*-failure guards in PromptRenderer (unreachable with a valid static pattern; deliberate fail-loud contract).
16 2026-10-26 Bug fix (caught by the new JS tests): RenderPreview.vue's Use button ran @click="emit('use', { ...values.value })" in the template, where the top-level values ref is auto-unwrapped — so it spread undefined and always emitted {}, making the real UI's /use flow always 422 ("missing variables"). Moved the emit into a script-scope emitUse() method bound via @click="emitUse"; the button now emits the actually entered values (§8).
17 2026-10-26 JS test harness complete (fulfilling #12): 64 Vitest tests / 10 files under tests/js/ with the shared Inertia stub + helpers, all green. Coverage of resources/js/ (excl. disclosed app.js entry wiring): 100% lines / 99.57% statements / 96.31% branches / 98.82% functions — ≥ 95% on every metric. Combined with PHP (60 tests, 99.05% lines), the ≥ 95% application-code coverage requirement (§9) is met for app/ + resources/js/.