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

331 lines
20 KiB
Markdown

# 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
```sql
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 `NULL`s 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/`. |