docs(12): create phase plan
This commit is contained in:
1 parent
de5ee4c301
commit
721442d1cb
3 files changed
+702
-1
No files matched your search
@@ -0,0 +1,408 @@
|
||||
---
|
||||
phase: 12-library-crud-data-integrity
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- backend/library/crud.go
|
||||
- backend/events/events.go
|
||||
- frontend/src/events.ts
|
||||
- backend/queue/queue.go
|
||||
autonomous: true
|
||||
requirements: [LIB-01, LIB-02, LIB-03, DATA-02, DATA-03, PLAY-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "AddLibrary creates a library row, emits LibraryAdded event, and triggers ScanLibrary"
|
||||
- "RenameLibrary validates uniqueness and length, updates name, emits LibraryRenamed event"
|
||||
- "RemoveLibrary atomically deletes tracks, populates phantom metadata on playlist_tracks, deletes orphaned entities, deletes the library row, rebuilds FTS5 index, and emits LibraryRemoved event"
|
||||
- "Orphan cleanup correctly handles the dual artist_credit FK (recordings + release_groups)"
|
||||
- "Queue tracks from a removed library are cascade-deleted and queue state is compacted"
|
||||
- "Currently-playing track from a removed library causes playback to stop before removal proceeds"
|
||||
artifacts:
|
||||
- path: "backend/library/crud.go"
|
||||
provides: "AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact methods"
|
||||
exports: ["AddLibrary", "RenameLibrary", "RemoveLibrary", "GetRemovalImpact", "RemovalSummary", "RemovalImpact"]
|
||||
- path: "backend/events/events.go"
|
||||
provides: "LibraryAdded, LibraryRenamed, LibraryRemoved event constants"
|
||||
contains: "LibraryAdded"
|
||||
- path: "frontend/src/events.ts"
|
||||
provides: "Regenerated event constants"
|
||||
contains: "LibraryAdded"
|
||||
key_links:
|
||||
- from: "backend/library/crud.go"
|
||||
to: "backend/library/scan_queue.go"
|
||||
via: "ScanLibrary call after AddLibrary"
|
||||
pattern: "l\\.ScanLibrary"
|
||||
- from: "backend/library/crud.go"
|
||||
to: "backend/database/search.go"
|
||||
via: "RebuildSearchIndex after removal"
|
||||
pattern: "RebuildSearchIndex"
|
||||
- from: "backend/library/crud.go"
|
||||
to: "backend/queue/queue.go"
|
||||
via: "Queue compaction after cascade delete"
|
||||
pattern: "CompactAfterLibraryRemoval"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement the backend Library CRUD API (AddLibrary, RenameLibrary, RemoveLibrary) with full data integrity: orphan cleanup, phantom track conversion, FTS5 rebuild, queue compaction, and event emission.
|
||||
|
||||
Purpose: This is the core backend for Phase 12 — all frontend library management UI depends on these Wails-bound methods.
|
||||
Output: `backend/library/crud.go` with all CRUD methods, updated events, queue compaction method.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/12-library-crud-data-integrity/12-RESEARCH.md
|
||||
@.planning/phases/12-library-crud-data-integrity/12-CONTEXT.md
|
||||
@.planning/phases/11-per-library-scan-pipeline/11-01-SUMMARY.md
|
||||
@.planning/phases/10-schema-migration/10-01-SUMMARY.md
|
||||
|
||||
@backend/library/library.go
|
||||
@backend/library/scan_queue.go
|
||||
@backend/library/rescan.go
|
||||
@backend/library/query.go
|
||||
@backend/events/events.go
|
||||
@backend/database/search.go
|
||||
@backend/queue/queue.go
|
||||
@backend/database/sql/queries/libraries.sql
|
||||
@backend/database/sql/schemas/_libraries.sql
|
||||
@backend/database/sql/schemas/audio_files.sql
|
||||
@backend/database/sql/schemas/playlist_tracks.sql
|
||||
@backend/player/player.go
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||
|
||||
From backend/library/scan_queue.go:
|
||||
```go
|
||||
func (l *Library) ScanLibrary(id int64) error
|
||||
func (l *Library) ScanAllLibraries() error
|
||||
func (l *Library) CancelCurrentScan()
|
||||
func (l *Library) CancelAllScans()
|
||||
```
|
||||
|
||||
From backend/library/library.go:
|
||||
```go
|
||||
type Library struct {
|
||||
ctx context.Context
|
||||
db *database.DB
|
||||
conf *config.Config
|
||||
logger *slog.Logger
|
||||
// ... scan state fields, mu sync.Mutex
|
||||
}
|
||||
```
|
||||
|
||||
From backend/database/search.go:
|
||||
```go
|
||||
func (d *DB) RebuildSearchIndex() error
|
||||
```
|
||||
|
||||
From backend/database/sql/queries/libraries.sql:
|
||||
```sql
|
||||
-- name: CreateLibrary :one
|
||||
INSERT INTO libraries (name, path) VALUES (?, ?) RETURNING *;
|
||||
-- name: GetLibrary :one
|
||||
SELECT * FROM libraries WHERE id = ? LIMIT 1;
|
||||
-- name: GetLibraryByPath :one
|
||||
SELECT * FROM libraries WHERE path = ? LIMIT 1;
|
||||
-- name: GetAllLibraries :many
|
||||
SELECT * FROM libraries ORDER BY name;
|
||||
-- name: UpdateLibraryName :exec
|
||||
UPDATE libraries SET name = ? WHERE id = ?;
|
||||
-- name: DeleteLibrary :exec
|
||||
DELETE FROM libraries WHERE id = ?;
|
||||
-- name: CountLibraries :one
|
||||
SELECT COUNT(*) AS count FROM libraries;
|
||||
-- name: CountAudioFilesByLibrary :one
|
||||
SELECT COUNT(*) AS count FROM audio_files WHERE library_id = ?;
|
||||
```
|
||||
|
||||
From backend/queue/queue.go:
|
||||
```go
|
||||
func (q *Queue) Clear()
|
||||
func (q *Queue) EmitCurrentState()
|
||||
func (q *Queue) GetState() State
|
||||
type TrackLoader interface {
|
||||
IsPlaying() bool
|
||||
CurrentPositionSeconds() (int, error)
|
||||
UnloadTrack()
|
||||
}
|
||||
```
|
||||
|
||||
From backend/events/events.go:
|
||||
```go
|
||||
// Library events.
|
||||
const (
|
||||
LibraryScanStarted = "LibraryScanStarted"
|
||||
LibraryScanProgress = "LibraryScanProgress"
|
||||
LibraryScanComplete = "LibraryScanComplete"
|
||||
)
|
||||
```
|
||||
|
||||
From backend/player/player.go:
|
||||
```go
|
||||
func (p *Player) IsPlaying() bool
|
||||
func (p *Player) UnloadTrack()
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Implement Library CRUD methods and orphan cleanup pipeline</name>
|
||||
<files>
|
||||
backend/library/crud.go
|
||||
backend/events/events.go
|
||||
frontend/src/events.ts
|
||||
</files>
|
||||
<action>
|
||||
Create `backend/library/crud.go` with the following methods on the `Library` struct:
|
||||
|
||||
**Types:**
|
||||
```go
|
||||
// RemovalImpact contains pre-removal counts for the confirmation dialog.
|
||||
type RemovalImpact struct {
|
||||
TrackCount int64 `json:"trackCount"`
|
||||
PlaylistsAffected int64 `json:"playlistsAffected"`
|
||||
QueueItemCount int64 `json:"queueItemCount"`
|
||||
}
|
||||
|
||||
// RemovalSummary contains post-removal counts for the toast notification.
|
||||
type RemovalSummary struct {
|
||||
TracksDeleted int64 `json:"tracksDeleted"`
|
||||
ArtistsRemoved int64 `json:"artistsRemoved"`
|
||||
AlbumsRemoved int64 `json:"albumsRemoved"`
|
||||
GenresRemoved int64 `json:"genresRemoved"`
|
||||
PlaylistsAffected int64 `json:"playlistsAffected"`
|
||||
QueueItemsRemoved int64 `json:"queueItemsRemoved"`
|
||||
}
|
||||
```
|
||||
|
||||
**AddLibrary(path string) (\*sqlcgen.Library, error):**
|
||||
- Validate path exists with `os.Stat`
|
||||
- Auto-name from `filepath.Base(path)`
|
||||
- Call `l.db.Queries.CreateLibrary(l.ctx, ...)` (the path UNIQUE constraint prevents duplicate paths)
|
||||
- Emit `events.LibraryAdded` event with the library struct
|
||||
- Start scanning async: `go func() { l.ScanLibrary(lib.ID) }()` — log error if it fails
|
||||
- Return the created library
|
||||
|
||||
**RenameLibrary(id int64, newName string) error:**
|
||||
- Trim and validate: 1-50 chars, non-empty
|
||||
- Check uniqueness: call `GetAllLibraries`, iterate to find conflicting name (excluding self). Use application-level validation per research recommendation (no schema migration needed).
|
||||
- Call `l.db.Queries.UpdateLibraryName(l.ctx, ...)`
|
||||
- Emit `events.LibraryRenamed` with `map[string]any{"id": id, "name": newName}`
|
||||
|
||||
**GetRemovalImpact(libraryID int64) (\*RemovalImpact, error):**
|
||||
- Three read-only queries (all hand-crafted SQL with SAFETY comments):
|
||||
- Track count: `SELECT COUNT(*) FROM audio_files WHERE library_id = ?`
|
||||
- Playlists affected: `SELECT COUNT(DISTINCT pt.playlist_id) FROM playlist_tracks pt JOIN audio_files af ON pt.audio_file_id = af.id WHERE af.library_id = ?`
|
||||
- Queue items: `SELECT COUNT(*) FROM queue_tracks qt JOIN audio_files af ON qt.audio_file_id = af.id WHERE af.library_id = ?`
|
||||
|
||||
**RemoveLibrary(id int64) (\*RemovalSummary, error):**
|
||||
This is the critical method. Follow the exact order from RESEARCH.md to avoid the phantom metadata pitfall:
|
||||
|
||||
1. **Cancel active scan** — If this library is currently scanning, cancel it and remove from queue. Call `l.cancelLibraryScan(id)` (new unexported helper that checks `l.currentScanLibraryID` and scan queue).
|
||||
2. **Stop playback if needed** — Check if the currently-playing track belongs to this library via a query: `SELECT COUNT(*) FROM audio_files WHERE library_id = ? AND file_path = ?` where the file_path comes from `l.player.GetCurrentFilePath()`. Need to expose a way to check — add a `currentTrackBelongsToLibrary` helper that uses the Queue to get the current track's file path and checks it against the library. If it matches, call `l.player.UnloadTrack()`.
|
||||
3. **Pre-count** for summary (track count, queue items affected, playlists affected).
|
||||
4. **Begin transaction** — `l.db.DB().BeginTx(l.ctx, nil)`
|
||||
5. **Populate phantom metadata** — MUST run BEFORE delete. Hand-crafted SQL UPDATE that copies live track metadata into phantom columns on playlist_tracks for tracks belonging to this library. See 12-RESEARCH.md Pattern 3 for the exact SQL.
|
||||
6. **Delete audio_files** — `DELETE FROM audio_files WHERE library_id = ?`. This triggers CASCADE on queue_tracks and SET NULL on playlist_tracks.audio_file_id.
|
||||
7. **Delete orphaned recordings** — `DELETE FROM recordings WHERE id NOT IN (SELECT DISTINCT recording_id FROM audio_files)`
|
||||
8. **Delete orphaned recording_genres** — `DELETE FROM recording_genres WHERE recording_id NOT IN (SELECT id FROM recordings)`
|
||||
9. **Delete orphaned release_group_recordings** — `DELETE FROM release_group_recordings WHERE recording_id NOT IN (SELECT id FROM recordings)`
|
||||
10. **Delete orphaned release_groups** — `DELETE FROM release_groups WHERE id NOT IN (SELECT DISTINCT release_group_id FROM release_group_recordings)`
|
||||
11. **Delete orphaned artist_credits** — CRITICAL: check BOTH recordings AND release_groups: `DELETE FROM artist_credit WHERE id NOT IN (SELECT DISTINCT artist_credit_id FROM recordings) AND id NOT IN (SELECT DISTINCT album_artist_credit_id FROM release_groups WHERE album_artist_credit_id IS NOT NULL)`
|
||||
12. **Delete orphaned artist_credit_artists** — `DELETE FROM artist_credit_artist WHERE credit_id NOT IN (SELECT id FROM artist_credit)`
|
||||
13. **Delete orphaned artists** — `DELETE FROM artists WHERE id NOT IN (SELECT DISTINCT artist_id FROM artist_credit_artist)`
|
||||
14. **Delete orphaned genres** — `DELETE FROM genres WHERE id NOT IN (SELECT DISTINCT genre_id FROM recording_genres)`
|
||||
15. **Collect orphaned cover_art file paths** — `SELECT file_path FROM cover_art WHERE id NOT IN (SELECT DISTINCT cover_art_id FROM release_groups WHERE cover_art_id IS NOT NULL)` — store in a slice for post-commit cleanup.
|
||||
16. **Delete orphaned cover_art rows** — `DELETE FROM cover_art WHERE id NOT IN (SELECT DISTINCT cover_art_id FROM release_groups WHERE cover_art_id IS NOT NULL)`
|
||||
17. **Delete library row** — `DELETE FROM libraries WHERE id = ?`
|
||||
18. **Commit transaction**
|
||||
19. **Post-commit: Rebuild FTS5** — `l.db.RebuildSearchIndex()` (cannot run inside transaction)
|
||||
20. **Post-commit: Delete orphaned cover art files** — iterate collected paths, `os.Remove()`, log warnings on failure
|
||||
21. **Post-commit: Compact queue** — Call the new `l.queue.CompactAfterLibraryRemoval()` method (see Task 2)
|
||||
22. **Emit events** — `events.LibraryRemoved` with `map[string]any{"id": id, "summary": summary}`
|
||||
23. **Return summary**
|
||||
|
||||
All hand-crafted SQL statements MUST have SAFETY comments following the project convention: `// SAFETY: [reason sqlc can't handle] + [safety assurance]`.
|
||||
|
||||
**cancelLibraryScan(id int64):**
|
||||
Unexported helper. Check if `l.currentScanLibraryID` matches `id` — if so, call `CancelCurrentScan()`. Also remove the library from the scan queue slice (filter it out under `l.scanMu` lock).
|
||||
|
||||
**currentTrackBelongsToLibrary(libraryID int64) bool:**
|
||||
Unexported helper. Get the current track file path from the queue (need to check if queue has a method to expose this, or query via `q.GetState().Tracks[q.GetState().CurrentIndex].FilePath`). Then query `SELECT library_id FROM audio_files WHERE file_path = ?` and compare.
|
||||
|
||||
Actually — for stopping playback: the Library struct doesn't directly hold a reference to Player. Use the existing `RescanHooks.PreClear` pattern or add a `StopPlaybackHook func()` field on Library. In `app.go` OnStartup, wire it:
|
||||
```go
|
||||
yj.library.StopPlaybackHook = func() {
|
||||
yj.player.UnloadTrack()
|
||||
}
|
||||
```
|
||||
But that's for stopping unconditionally. For checking if the current track belongs to a library, it's simpler to do the check inside `RemoveLibrary` via a hand-crafted query: `SELECT COUNT(*) FROM audio_files af JOIN queue_tracks qt ON qt.audio_file_id = af.id WHERE af.library_id = ? AND qt.position = (SELECT current_position FROM queue LIMIT 1)`. If count > 0, call the hook.
|
||||
|
||||
Better approach: add two fields to Library:
|
||||
```go
|
||||
// StopPlaybackForLibrary is called before library removal if the
|
||||
// currently-playing track belongs to the library being removed.
|
||||
// Wired in app.go OnStartup.
|
||||
StopPlaybackForLibrary func()
|
||||
// GetQueueState returns the current queue state for library removal checks.
|
||||
// Wired in app.go OnStartup.
|
||||
GetQueueState func() (currentFilePath string, ok bool)
|
||||
```
|
||||
|
||||
Actually, the simplest approach that follows existing patterns: Library already has a `rescanHooks RescanHooks` field. Add a new field:
|
||||
```go
|
||||
removalHooks struct {
|
||||
stopPlayback func()
|
||||
compactQueue func()
|
||||
}
|
||||
```
|
||||
Wire in app.go:
|
||||
```go
|
||||
yj.library.SetRemovalHooks(library.RemovalHooks{
|
||||
StopPlayback: func() { yj.player.UnloadTrack() },
|
||||
CompactQueue: func() { yj.queue.CompactAfterLibraryRemoval() },
|
||||
})
|
||||
```
|
||||
Then for the "does current track belong to this library" check, just use a DB query in the transaction-preparation stage.
|
||||
|
||||
**Add to events.go:**
|
||||
```go
|
||||
// Library CRUD events.
|
||||
const (
|
||||
LibraryAdded = "LibraryAdded"
|
||||
LibraryRenamed = "LibraryRenamed"
|
||||
LibraryRemoved = "LibraryRemoved"
|
||||
)
|
||||
```
|
||||
|
||||
Then run `go generate ./backend/events/...` to regenerate `frontend/src/events.ts`.
|
||||
|
||||
Use the SAFETY comment convention for ALL hand-crafted SQL (every ExecContext/QueryContext/QueryRowContext call).
|
||||
Follow error sentinel convention (err113): define `var errLibraryNameEmpty`, `var errLibraryNameTooLong`, `var errLibraryNameDuplicate`, `var errLibraryPathNotExist` as package-level vars.
|
||||
Follow nlreturn convention: blank line after early return blocks.
|
||||
Follow godot convention: doc comments end with periods.
|
||||
Follow wsl convention: blank line before var/const declarations.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /mnt/vault/dev/golang/yellowjacket && go build ./backend/... && go vet ./backend/library/... && golangci-lint run ./backend/library/crud.go ./backend/events/events.go</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- crud.go exists with AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact, cancelLibraryScan methods
|
||||
- All hand-crafted SQL has SAFETY comments
|
||||
- RemoveLibrary follows exact order: phantom populate → delete audio_files → orphan cleanup → delete library → commit → FTS5 rebuild → cover art file cleanup → queue compact → events
|
||||
- events.go has LibraryAdded, LibraryRenamed, LibraryRemoved constants
|
||||
- events.ts is regenerated
|
||||
- `go build ./backend/...` passes
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add queue compaction method and wire removal hooks in app.go</name>
|
||||
<files>
|
||||
backend/queue/queue.go
|
||||
backend/app.go
|
||||
backend/library/crud.go
|
||||
</files>
|
||||
<action>
|
||||
**Queue compaction method** — Add to `backend/queue/queue.go`:
|
||||
|
||||
```go
|
||||
// CompactAfterLibraryRemoval reloads queue state from the database
|
||||
// after a library removal has cascade-deleted queue_tracks rows.
|
||||
// It resets currentIndex to 0 (or -1 if empty), clears shuffleOrder,
|
||||
// unloads the current track if it was removed, and emits QueueChanged.
|
||||
func (q *Queue) CompactAfterLibraryRemoval() {
|
||||
```
|
||||
|
||||
Implementation:
|
||||
1. Acquire `q.mu`
|
||||
2. Call `q.db.Queries.GetQueueTracks(q.db.Ctx)` to get the surviving queue tracks from DB
|
||||
3. Rebuild `q.tracks` from the DB rows
|
||||
4. If the previous current track's file path is no longer in the new track list:
|
||||
- Set `q.currentIndex = 0` (or -1 if empty)
|
||||
- Call `q.player.UnloadTrack()` if player is set
|
||||
5. Else: find the current track in the new list and update `q.currentIndex`
|
||||
6. Clear `q.shuffleOrder = nil` (will be regenerated on next shuffle toggle)
|
||||
7. Call `q.commitMutation(false)` to persist the compacted state
|
||||
8. Call `q.emitQueueChanged()` to push update to frontend
|
||||
|
||||
Need to check if `GetQueueTracks` query exists. If not, the queue persistence uses its own reload pattern. Check `backend/queue/persistence.go` for the restore pattern and reuse it. The key point is that cascade DELETE already removed the rows from `queue_tracks` — we just need to reload and reindex.
|
||||
|
||||
**Wire removal hooks in app.go** — In `OnStartup`, after existing hook wiring, add:
|
||||
|
||||
```go
|
||||
yj.library.SetRemovalHooks(library.RemovalHooks{
|
||||
StopPlayback: func() { yj.player.UnloadTrack() },
|
||||
CompactQueue: func() { yj.queue.CompactAfterLibraryRemoval() },
|
||||
})
|
||||
```
|
||||
|
||||
**Add RemovalHooks type to crud.go** (or library.go):
|
||||
|
||||
```go
|
||||
// RemovalHooks contains callbacks invoked during library removal.
|
||||
// These break circular dependencies between library, player, and queue packages.
|
||||
type RemovalHooks struct {
|
||||
// StopPlayback stops the currently-playing track.
|
||||
StopPlayback func()
|
||||
// CompactQueue reloads queue state after cascade deletes.
|
||||
CompactQueue func()
|
||||
}
|
||||
|
||||
func (l *Library) SetRemovalHooks(h RemovalHooks) {
|
||||
l.removalHooks = h
|
||||
}
|
||||
```
|
||||
|
||||
Add `removalHooks RemovalHooks` field to the Library struct in library.go.
|
||||
|
||||
Make sure RemoveLibrary in crud.go calls these hooks at the appropriate points (StopPlayback before the transaction if current track belongs to the library, CompactQueue after the transaction commits).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /mnt/vault/dev/golang/yellowjacket && go build ./... && go vet ./backend/queue/... ./backend/library/... && golangci-lint run ./backend/queue/queue.go ./backend/app.go</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- CompactAfterLibraryRemoval method exists on Queue
|
||||
- RemovalHooks type exists with StopPlayback and CompactQueue callbacks
|
||||
- app.go wires removal hooks in OnStartup
|
||||
- Library struct has removalHooks field
|
||||
- `go build ./...` passes (full build including frontend binding generation)
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `go build ./...` — full project builds with no errors
|
||||
2. `go vet ./backend/...` — no vet issues
|
||||
3. `golangci-lint run ./backend/library/ ./backend/queue/ ./backend/events/` — no lint issues
|
||||
4. `go test ./backend/database/... -count=1` — existing database tests still pass
|
||||
5. `go test ./backend/queue/... -count=1` — existing queue tests still pass
|
||||
6. `go test ./backend/library/... -count=1` — existing library tests still pass
|
||||
7. Verify events.ts was regenerated with new event constants
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All four CRUD methods (AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact) are implemented and compile
|
||||
- RemoveLibrary follows the correct order: phantom populate → delete → orphan cleanup → commit → FTS5 rebuild
|
||||
- Queue compaction handles cascade-deleted tracks correctly
|
||||
- All events (LibraryAdded, LibraryRenamed, LibraryRemoved) are defined and auto-generated to frontend
|
||||
- Existing tests pass with no regressions
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/12-library-crud-data-integrity/12-01-SUMMARY.md`
|
||||
</output>
|
||||
Reference in new issue
Block a user