From 307e4eff92bbe63bc76ca0fe203e51f234b26d41 Mon Sep 17 00:00:00 2001 From: Thomas Richter Date: Sat, 31 Jan 2026 12:45:42 +0100 Subject: [PATCH] docs(04): research phase domain Phase 4: Tags & Organization - Standard stack identified (Drizzle relations v1, Svelecte v5) - Architecture patterns documented (many-to-many junction table, case-insensitive tags) - Pitfalls catalogued (N+1 queries, case sensitivity, atomic tag updates) Co-Authored-By: Claude Opus 4.5 --- .planning/phases/04-tags/04-RESEARCH.md | 364 ++++++++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 .planning/phases/04-tags/04-RESEARCH.md diff --git a/.planning/phases/04-tags/04-RESEARCH.md b/.planning/phases/04-tags/04-RESEARCH.md new file mode 100644 index 0000000..71b547a --- /dev/null +++ b/.planning/phases/04-tags/04-RESEARCH.md @@ -0,0 +1,364 @@ +# Phase 4: Tags & Organization - Research + +**Researched:** 2026-01-31 +**Domain:** Tag management, many-to-many relationships, autocomplete UI +**Confidence:** HIGH + +## Summary + +This phase adds tagging capabilities and organizational features (pinning, due dates) to entries. The core challenge is implementing a proper many-to-many relationship between entries and tags using Drizzle ORM with SQLite, while providing a user-friendly tag input with autocomplete. + +The schema already has `pinned` (boolean) and `dueDate` (text) columns on the entries table, so ORG-01 and ORG-02 only require UI work. The tag system requires a new `tags` table and `entry_tags` junction table with case-insensitive tag storage. + +**Primary recommendation:** Use a normalized three-table schema (entries, tags, entry_tags) with case-insensitive unique index on tag names, and use Svelecte v5 for the tag input component with autocomplete and tag creation. + +## Standard Stack + +The established libraries/tools for this domain: + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| drizzle-orm | 0.45.1 | Database ORM | Already in project, handles relations via Query API v1 | +| svelecte | 5.x | Tag input with autocomplete | Svelte 5 native, supports multi-select, tag creation, filtering | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| nanoid | 5.x | ID generation | Already in project, use for tag IDs | +| HTML5 date input | native | Due date picker | Native browser support, no extra dependency | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| Svelecte | svelte-tags-input | Less certain Svelte 5 compatibility; Svelecte explicitly supports Svelte 5 | +| Svelecte | Custom component | More control but significant effort for autocomplete, filtering, keyboard nav | +| Normalized tags table | JSON array on entry | Harder to search, no tag reuse, no autocomplete across entries | + +**Installation:** +```bash +npm install svelecte +``` + +## Architecture Patterns + +### Recommended Database Schema +``` +entries (existing) +├── id (PK) +├── pinned (already exists) +├── dueDate (already exists) +└── ... + +tags (new) +├── id (PK, nanoid) +├── name (unique case-insensitive) +└── createdAt + +entry_tags (junction table, new) +├── entryId (FK -> entries.id, cascade delete) +├── tagId (FK -> tags.id, cascade delete) +└── (composite PK on entryId + tagId) +``` + +### Pattern 1: Case-Insensitive Tag Storage +**What:** Store tags with a unique index on lowercased name +**When to use:** All tag operations to ensure "Work" and "work" are the same tag +**Example:** +```typescript +// Source: https://orm.drizzle.team/docs/guides/unique-case-insensitive-email +import { sql, SQL } from 'drizzle-orm'; +import { sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; + +export function lower(column: AnySQLiteColumn): SQL { + return sql`lower(${column})`; +} + +export const tags = sqliteTable('tags', { + id: text('id').primaryKey(), + name: text('name').notNull(), + createdAt: text('created_at').notNull().$defaultFn(() => new Date().toISOString()), +}, (table) => [ + uniqueIndex('tagNameUniqueIndex').on(lower(table.name)), +]); +``` + +### Pattern 2: Junction Table with Composite Primary Key +**What:** Link entries to tags through a many-to-many junction table +**When to use:** All entry-tag associations +**Example:** +```typescript +// Source: https://orm.drizzle.team/docs/relations +import { sqliteTable, text, primaryKey } from 'drizzle-orm/sqlite-core'; + +export const entryTags = sqliteTable('entry_tags', { + entryId: text('entry_id').notNull().references(() => entries.id, { onDelete: 'cascade' }), + tagId: text('tag_id').notNull().references(() => tags.id, { onDelete: 'cascade' }), +}, (t) => [ + primaryKey({ columns: [t.entryId, t.tagId] }), +]); +``` + +### Pattern 3: Drizzle Relations v1 for Many-to-Many +**What:** Define relations to enable relational queries with the `with` clause +**When to use:** Fetching entries with their tags in a single query +**Example:** +```typescript +// Source: https://orm.drizzle.team/docs/rqb +import { relations } from 'drizzle-orm'; + +export const entriesRelations = relations(entries, ({ many }) => ({ + entryTags: many(entryTags), +})); + +export const tagsRelations = relations(tags, ({ many }) => ({ + entryTags: many(entryTags), +})); + +export const entryTagsRelations = relations(entryTags, ({ one }) => ({ + entry: one(entries, { + fields: [entryTags.entryId], + references: [entries.id], + }), + tag: one(tags, { + fields: [entryTags.tagId], + references: [tags.id], + }), +})); +``` + +### Pattern 4: Querying Entries with Tags +**What:** Use Drizzle's relational queries to fetch entries with nested tags +**When to use:** Loading entry list with all associated tags +**Example:** +```typescript +// Source: https://orm.drizzle.team/docs/rqb +const entriesWithTags = await db.query.entries.findMany({ + with: { + entryTags: { + with: { + tag: true, + }, + }, + }, +}); + +// Transform to flat tag array if needed +const result = entriesWithTags.map(entry => ({ + ...entry, + tags: entry.entryTags.map(et => et.tag), +})); +``` + +### Pattern 5: Svelecte for Tag Input +**What:** Use Svelecte with creatable mode for tag input with autocomplete +**When to use:** Tag input UI in entry edit form +**Example:** +```svelte + + + + onchange(e.detail)} +/> +``` + +### Anti-Patterns to Avoid +- **Storing tags as JSON array:** Prevents efficient querying, no autocomplete, can't find "all entries with tag X" +- **Case-sensitive tag matching:** Leads to duplicate tags like "Work", "work", "WORK" +- **Not using onDelete cascade:** Orphaned junction records when entry or tag deleted +- **Fetching tags in separate query per entry:** N+1 query problem; use relational queries instead + +## Don't Hand-Roll + +Problems that look simple but have existing solutions: + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Tag autocomplete | Custom dropdown with keyboard nav | Svelecte | Keyboard navigation, accessibility, fuzzy search, virtual scrolling | +| Multi-select tag input | Custom chip input | Svelecte with `multiple` | Tag removal UX, paste support, styling | +| Date picker | Custom calendar component | HTML5 `` | Native browser support, mobile-friendly, accessible | +| Case-insensitive unique | Runtime deduplication | SQLite unique index with `lower()` | Database-level enforcement, handles concurrent inserts | + +**Key insight:** Autocomplete dropdowns have many edge cases (keyboard navigation, accessibility, scroll behavior, mobile touch) that Svelecte handles out of the box. + +## Common Pitfalls + +### Pitfall 1: Case Sensitivity in Tag Matching +**What goes wrong:** User creates "Work" tag, then types "work" and creates duplicate +**Why it happens:** Default SQLite text comparison is case-sensitive +**How to avoid:** Create unique index on `lower(name)`, normalize during insert/lookup +**Warning signs:** Multiple tags that differ only in case appearing in autocomplete + +### Pitfall 2: N+1 Queries When Loading Tags +**What goes wrong:** Fetching entry list, then making separate query for each entry's tags +**Why it happens:** Not using Drizzle's relational queries with `with` clause +**How to avoid:** Always use `db.query.entries.findMany({ with: { entryTags: { with: { tag: true }}}})` +**Warning signs:** Page load slows linearly with entry count + +### Pitfall 3: Orphaned Tags +**What goes wrong:** After removing tag from all entries, tag still appears in autocomplete +**Why it happens:** Junction records deleted but tag record remains +**How to avoid:** Either accept orphaned tags (they're useful for autocomplete) OR periodically clean up tags with no associations +**Warning signs:** Autocomplete shows tags that aren't used anywhere + +### Pitfall 4: Race Condition Creating New Tags +**What goes wrong:** Two users simultaneously create "urgent" tag, one fails with unique constraint error +**Why it happens:** Check-then-insert pattern without proper handling +**How to avoid:** Use "INSERT OR IGNORE" or try/catch with lookup on conflict +**Warning signs:** Intermittent errors when creating new tags + +### Pitfall 5: Not Updating Tags Atomically +**What goes wrong:** Entry has ["work", "urgent"], user saves ["work", "home"], "urgent" not removed +**Why it happens:** Only inserting new tags, not removing old ones +**How to avoid:** Replace pattern: delete all entry_tags for entry, then insert new set +**Warning signs:** Tags accumulate on entries, can't remove tags + +## Code Examples + +Verified patterns from official sources: + +### Tag Repository Operations +```typescript +// Find or create tag by name (case-insensitive) +async findOrCreate(name: string): Tag { + const normalizedName = name.trim(); + + // Try to find existing (case-insensitive) + const existing = db + .select() + .from(tags) + .where(sql`lower(${tags.name}) = lower(${normalizedName})`) + .get(); + + if (existing) return existing; + + // Create new tag + const newTag = { + id: nanoid(), + name: normalizedName, + createdAt: new Date().toISOString(), + }; + db.insert(tags).values(newTag).run(); + return newTag; +} + +// Get all tags for autocomplete +getAll(): Tag[] { + return db.select().from(tags).orderBy(asc(tags.name)).all(); +} +``` + +### Updating Entry Tags +```typescript +// Replace all tags for an entry atomically +async updateEntryTags(entryId: string, tagNames: string[]): void { + // Delete existing associations + db.delete(entryTags).where(eq(entryTags.entryId, entryId)).run(); + + // Find or create each tag and create associations + for (const name of tagNames) { + const tag = await this.findOrCreate(name); + db.insert(entryTags).values({ entryId, tagId: tag.id }).run(); + } +} +``` + +### Form Action for Tags +```typescript +// In +page.server.ts +updateTags: async ({ request }) => { + const formData = await request.formData(); + const id = formData.get('id')?.toString(); + const tagsJson = formData.get('tags')?.toString() || '[]'; + + if (!id) return fail(400, { error: 'Entry ID required' }); + + const tagNames = JSON.parse(tagsJson) as string[]; + await tagRepository.updateEntryTags(id, tagNames); + + return { success: true }; +} +``` + +### Native Date Input for Due Date +```svelte + + +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Drizzle Relations v1 | Relations v2 with `through` | 1.0.0-beta.1 | Simpler many-to-many queries (not available in 0.45.x) | +| Manual joins for M:M | `with` clause in relational queries | 0.17.0+ | Single query, automatic aggregation | +| Svelte stores for reactivity | Svelte 5 runes ($state, $props) | Svelte 5 | More explicit reactivity | + +**Note on Drizzle version:** Project uses Drizzle 0.45.1 which uses Relations v1 API. Relations v2 with `through` syntax requires 1.0.0-beta.1+. The patterns documented here use v1 API which is stable and well-supported. + +**Deprecated/outdated:** +- Svelecte v4.x for Svelte 4 projects (use v5.x for Svelte 5) +- svelte-tags-input may have Svelte 5 compatibility issues (not verified) + +## Open Questions + +Things that couldn't be fully resolved: + +1. **Orphaned tag cleanup strategy** + - What we know: When tag removed from all entries, tag record remains + - What's unclear: Should orphaned tags be kept (useful for autocomplete history) or cleaned up? + - Recommendation: Keep orphaned tags for now, they provide autocomplete suggestions. Add cleanup later if tag list grows too large. + +2. **Tag rename cascading** + - What we know: Tags have unique IDs, name stored in tags table + - What's unclear: If user renames a tag, should it propagate to all entries? + - Recommendation: Not in scope for this phase. Tags are simple strings; if users want to "rename", they add new tag and remove old one. + +## Sources + +### Primary (HIGH confidence) +- [Drizzle ORM Relations](https://orm.drizzle.team/docs/relations) - Junction table pattern +- [Drizzle Query API v1](https://orm.drizzle.team/docs/rqb) - Relational queries with `with` clause +- [Drizzle Case-Insensitive Guide](https://orm.drizzle.team/docs/guides/unique-case-insensitive-email) - `lower()` helper function +- [Drizzle SQLite Column Types](https://orm.drizzle.team/docs/column-types/sqlite) - Schema definition +- [Svelecte GitHub](https://github.com/mskocik/svelecte) - Svelte 5 support confirmed (v5.x) + +### Secondary (MEDIUM confidence) +- [SQLite COLLATE NOCASE](https://www.designcise.com/web/tutorial/how-to-do-case-insensitive-comparisons-in-sqlite) - Case-insensitive patterns +- [Tagging System Database Design](https://www.geeksforgeeks.org/dbms/how-to-design-a-database-for-tagging-service/) - Normalized schema patterns + +### Tertiary (LOW confidence) +- svelte-tags-input Svelte 5 compatibility status - Unclear from docs, Svelecte is safer choice + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH - Drizzle patterns verified via official docs, Svelecte explicitly supports Svelte 5 +- Architecture: HIGH - Many-to-many junction table is standard pattern, verified with Drizzle docs +- Pitfalls: MEDIUM - Based on general database design principles and common issues with tagging systems + +**Research date:** 2026-01-31 +**Valid until:** 60 days (stable patterns, no fast-moving dependencies)