26 KiB
Phase 4: Shared Lists + Live Sync — Pattern Map
Mapped: 2026-06-09 Files analyzed: 18 new/modified files Analogs found: 16 / 18
File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
apps/api/src/db/schema.ts |
model (modify) | CRUD | self | exact |
apps/api/src/db/migrations/0002_lists_schema.sql |
migration | batch | 0001_calendars_user_url_unique.sql |
role-match |
apps/api/src/lib/listEmitter.ts |
utility | event-driven | none in codebase | no analog |
apps/api/src/routes/lists.ts |
route/controller | CRUD | apps/api/src/routes/events.ts |
exact |
apps/api/src/routes/sse.ts |
route (modify) | streaming | self | exact |
apps/api/src/index.ts |
config (modify) | request-response | self | exact |
apps/pwa/src/App.tsx |
component (modify) | request-response | self | exact |
apps/pwa/src/components/BottomTabBar.tsx |
component | request-response | apps/pwa/src/components/AppNav.tsx |
role-match |
apps/pwa/src/routes/ListsIndex.tsx |
component | CRUD | apps/pwa/src/components/CalendarShell.tsx |
role-match |
apps/pwa/src/routes/ListDetail.tsx |
component | CRUD + event-driven | apps/pwa/src/components/CalendarShell.tsx |
role-match |
apps/pwa/src/components/ListCard.tsx |
component | request-response | apps/pwa/src/components/DeleteConfirmationDialog.tsx |
role-match |
apps/pwa/src/components/ItemRow.tsx |
component | event-driven | apps/pwa/src/components/DeleteConfirmationDialog.tsx |
role-match |
apps/pwa/src/components/AddItemInput.tsx |
component | request-response | apps/pwa/src/components/DeleteConfirmationDialog.tsx |
role-match |
apps/pwa/src/components/CreateListSheet.tsx |
component | request-response | apps/pwa/src/components/DeleteConfirmationDialog.tsx |
role-match |
apps/pwa/src/components/LiveSyncIndicator.tsx |
component | event-driven | none — novel | no analog |
apps/pwa/src/components/ListsEmptyState.tsx |
component | request-response | apps/pwa/src/components/SkeletonCalendar.tsx |
role-match |
apps/pwa/src/hooks/useListSSE.ts |
hook | event-driven | none in codebase | no analog |
apps/pwa/src/api/listsClient.ts |
utility | request-response | apps/pwa/src/api/client.ts |
exact |
apps/pwa/src/store/listsStore.ts |
store | request-response | apps/pwa/src/store/calendarStore.ts |
exact |
Pattern Assignments
apps/api/src/db/schema.ts (model, CRUD — append new tables)
Analog: self — read apps/api/src/db/schema.ts lines 1–163 in full above.
Imports pattern (lines 1–13):
import {
mysqlTable,
varchar,
int,
timestamp,
boolean,
index,
unique,
} from 'drizzle-orm/mysql-core'
Note: mysqlEnum is imported for calendarOutbox but is not needed for list tables. Import only what the new tables use.
Table definition pattern (lines 40–54, memberCredentials — simplest table with FK):
export const memberCredentials = mysqlTable(
'member_credentials',
{
id: int().primaryKey().autoincrement(),
userId: int('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
encryptedPassword: text('encrypted_password').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
},
(t) => [index('idx_member_credentials_user_id').on(t.userId)],
)
Composite unique key pattern (lines 61–86, calendars):
(t) => [
index('idx_calendars_user_id').on(t.userId),
unique('uniq_calendar_user_url').on(t.userId, t.url),
]
New tables to append — follow the schema from RESEARCH.md §Database Schema Design exactly:
lists— int PK,owner_idFK tousers,name varchar(255),is_shared boolean DEFAULT true,created_at,updated_at; index onowner_idlistShares— int PK,list_idFK tolistscascade,user_idFK touserscascade,created_at; unique on(list_id, user_id), index onuser_idlistItems— int PK,list_idFK tolistscascade,text varchar(500),checked boolean DEFAULT false,rank varchar(255),created_at,updated_at; composite index on(list_id, rank), index on(list_id, checked)
apps/api/src/routes/lists.ts (route, CRUD)
Analog: apps/api/src/routes/events.ts (full file above)
File header doc-block pattern (lines 1–22 of events.ts):
/**
* Lists router — list + item CRUD with SSE fan-out trigger.
*
* Security:
* - All endpoints resolve currentUserId via resolveUserId (returns null → 401).
* - Access control: list must be owned by currentUser OR appear in list_shares.
* - Drizzle parameterized queries prevent SQL injection.
* - zod validates all write payloads (name max 255, text max 500).
*
* Mounted under /api/* in index.ts — behind oidcAuthMiddleware.
*/
Imports pattern (lines 24–37 of events.ts):
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { and, or, eq } from 'drizzle-orm'
import { db } from '../db/client.js'
import { lists, listItems, listShares, users } from '../db/schema.js'
import { getAuth } from '../auth/middleware.js'
import { upsertUser, deriveDisplayName } from '../auth/user.js'
import { publishListEvent } from '../lib/listEmitter.js'
import '../auth/devBypass.js'
export const listsRouter = new Hono()
resolveUserId helper — copy verbatim from events.ts lines 59–76. This function is duplicated per router (not extracted to a shared module) — maintain that pattern.
Zod schema pattern (lines 82–105 of events.ts):
const createListSchema = z.object({
name: z.string().min(1).max(255),
isShared: z.boolean().default(true),
})
const createItemSchema = z.object({
text: z.string().min(1).max(500),
})
// Per-field PATCH — enforce exactly one field per D-08
const patchItemSchema = z
.object({
checked: z.boolean(),
text: z.string().min(1).max(500),
position: z.string().min(1).max(255),
})
.partial()
.refine((obj) => Object.keys(obj).length === 1, {
message: 'PATCH must update exactly one field',
})
Route handler pattern — GET with auth + access check + try/catch (lines 122–221 of events.ts):
listsRouter.get('/', async (c) => {
const currentUserId = await resolveUserId(c)
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
try {
// SELECT lists WHERE owner_id = ? OR id IN (SELECT list_id FROM list_shares WHERE user_id = ?)
const rows = await db
.select({ /* ... */ })
.from(lists)
.where(or(eq(lists.ownerId, currentUserId), /* join with listShares */ ))
return c.json({ lists: rows })
} catch (err) {
console.error('[lists] DB query failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
}
})
Fan-out trigger pattern — call after every successful write:
// After insert/update/delete succeeds:
publishListEvent(listId, { type: 'item:added', listId, payload: newItem })
Ownership verification pattern (lines 326–339 of events.ts):
// Verify list access before any item mutation
const [listRow] = await db
.select({ ownerId: lists.ownerId })
.from(lists)
.where(eq(lists.id, listId))
if (!listRow) return c.json({ error: 'Not found' }, 404)
const isOwner = listRow.ownerId === currentUserId
const [shareRow] = isOwner ? [{}] : await db
.select({ listId: listShares.listId })
.from(listShares)
.where(and(eq(listShares.listId, listId), eq(listShares.userId, currentUserId)))
if (!isOwner && !shareRow) return c.json({ error: 'Access denied' }, 403)
apps/api/src/routes/sse.ts (route, streaming — extend existing)
Analog: self — apps/api/src/routes/sse.ts lines 1–40 (full file above).
Core streamSSE pattern (lines 28–40):
sseRouter.get('/heartbeat', (c) => {
return streamSSE(c, async (stream) => {
let id = 0
while (!stream.aborted) {
await stream.writeSSE({
data: JSON.stringify({ ts: new Date().toISOString(), id }),
event: 'heartbeat',
id: String(id++),
})
await stream.sleep(10_000)
}
})
})
New /lists endpoint extends this with:
resolveUserId(c)call first → 401 on null (same as events.ts pattern)getAccessibleListIds(userId)DB query beforestreamSSEcallsubscribeListEvents(listId, handler)loop insidestreamSSE- Heartbeat loop at 30s cadence (not 10s — Pangolin smoke test used 10s for the heartbeat, 30s is fine for production load)
- Cleanup:
unsubscribers.forEach(unsub => unsub())after the while loop exits
sseRouter.get('/lists', async (c) => {
const userId = await resolveUserId(c)
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
const accessibleListIds = await getAccessibleListIds(userId)
return streamSSE(c, async (stream) => {
const unsubscribers: Array<() => void> = []
for (const listId of accessibleListIds) {
const unsub = subscribeListEvents(listId, async (event) => {
if (stream.aborted) return
await stream.writeSSE({
data: JSON.stringify(event),
event: event.type,
id: `${listId}-${Date.now()}`,
})
})
unsubscribers.push(unsub)
}
let tick = 0
while (!stream.aborted) {
await stream.writeSSE({
data: JSON.stringify({ ts: new Date().toISOString() }),
event: 'heartbeat',
id: String(tick++),
})
await stream.sleep(30_000)
}
unsubscribers.forEach((unsub) => unsub())
})
})
apps/api/src/lib/listEmitter.ts (utility, event-driven)
No codebase analog — this is new. Use the pattern from RESEARCH.md Finding 1 verbatim:
import { EventEmitter } from 'node:events'
const emitter = new EventEmitter()
emitter.setMaxListeners(200)
export type ListEvent = {
type: 'item:added' | 'item:updated' | 'item:deleted' | 'list:updated' | 'list:deleted'
listId: number
payload: unknown
}
export function publishListEvent(listId: number, event: ListEvent): void {
emitter.emit(`list:${listId}`, event)
}
export function subscribeListEvents(
listId: number,
handler: (event: ListEvent) => void,
): () => void {
const channel = `list:${listId}`
emitter.on(channel, handler)
return () => emitter.off(channel, handler)
}
apps/api/src/index.ts (config, modify)
Analog: self — lines 1–83 (full file above).
Route mount pattern (lines 59–61):
app.route('/api/me', meRouter)
app.route('/api/events', eventsRouter)
app.route('/api/sse', sseRouter)
Add after sseRouter mount:
import { listsRouter } from './routes/lists.js'
// ...
app.route('/api/lists', listsRouter)
Auto-migrate pattern — add before startBrokerPoller() (line 65):
import { migrate } from 'drizzle-orm/mysql2/migrator'
// ...
await migrate(db, { migrationsFolder: './src/db/migrations' })
apps/pwa/src/App.tsx (component, modify)
Analog: self — lines 1–5 (full file above). Currently a one-liner.
Transform to (pattern from RESEARCH.md Finding 5):
import { BrowserRouter, Routes, Route, Navigate } from 'react-router'
import { CalendarShell } from './components/CalendarShell.js'
import { ListsIndex } from './routes/ListsIndex.js'
import { ListDetail } from './routes/ListDetail.js'
import { BottomTabBar } from './components/BottomTabBar.js'
export default function App() {
return (
<BrowserRouter>
<AppShell />
</BrowserRouter>
)
}
function AppShell() {
return (
<>
<Routes>
<Route path="/" element={<Navigate to="/calendar" replace />} />
<Route path="/calendar" element={<CalendarShell />} />
<Route path="/lists" element={<ListsIndex />} />
<Route path="/lists/:listId" element={<ListDetail />} />
</Routes>
<BottomTabBar />
</>
)
}
apps/pwa/src/components/BottomTabBar.tsx (component, request-response)
Analog: apps/pwa/src/components/AppNav.tsx (nav component with active-state links — check that file if needed for CSS token conventions)
Key pattern — NavLink with isActive callback (from RESEARCH.md Finding 5):
import { NavLink } from 'react-router'
import { CalendarDays, List } from 'lucide-react'
export function BottomTabBar() {
return (
<nav
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: '56px',
display: 'flex',
background: 'var(--color-surface)',
borderTop: '1px solid var(--color-border)',
zIndex: 100,
}}
>
<NavLink
to="/calendar"
className={({ isActive }) => isActive ? 'tab tab--active' : 'tab'}
>
<CalendarDays size={22} aria-hidden="true" />
<span>Calendar</span>
</NavLink>
<NavLink
to="/lists"
className={({ isActive }) => isActive ? 'tab tab--active' : 'tab'}
>
<List size={22} aria-hidden="true" />
<span>Lists</span>
</NavLink>
</nav>
)
}
CSS tokens: use var(--color-surface), var(--color-border), var(--color-text-primary), var(--color-text-secondary) — the existing token layer from Phase 2.
apps/pwa/src/api/listsClient.ts (utility, request-response)
Analog: apps/pwa/src/api/client.ts lines 1–53 (full pattern above).
Imports + credential pattern:
// credentials: 'include' on every fetch — session cookie required (same as client.ts)
const BASE = '/api'
async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
...init,
})
if (!res.ok) throw new Error(`${init?.method ?? 'GET'} ${path} failed: ${res.status}`)
return res
}
Type + function pattern (mirrors client.ts):
export interface List {
id: number
name: string
isShared: boolean
ownerId: number
}
export interface ListItem {
id: number
listId: number
text: string
checked: boolean
rank: string
}
export async function fetchLists(): Promise<{ lists: List[] }> {
return apiFetch('/lists').then((r) => r.json())
}
export async function createList(payload: { name: string; isShared: boolean }): Promise<{ id: number }> {
return apiFetch('/lists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then((r) => r.json())
}
export async function patchListItem(
itemId: number,
patch: { checked?: boolean } | { text: string } | { position: string },
): Promise<void> {
await apiFetch(`/list-items/${itemId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
}
apps/pwa/src/store/listsStore.ts (store, request-response)
Analog: apps/pwa/src/store/calendarStore.ts lines 1–89 (full file above).
Imports + create pattern (lines 26–27 of calendarStore.ts):
import { create } from 'zustand'
export interface ListsStore {
// UI-only state — no server data
activeTab: 'calendar' | 'lists'
createListSheetOpen: boolean
// ...
setActiveTab: (tab: 'calendar' | 'lists') => void
setCreateListSheetOpen: (open: boolean) => void
}
export const useListsStore = create<ListsStore>()((set) => ({
activeTab: 'calendar',
createListSheetOpen: false,
setActiveTab: (tab) => set({ activeTab: tab }),
setCreateListSheetOpen: (open) => set({ createListSheetOpen: open }),
}))
Convention from calendarStore.ts: no persist middleware used in this project — state is ephemeral (view persistence done manually with localStorage in calendarStore; lists UI state does not need persistence).
apps/pwa/src/routes/ListsIndex.tsx (component, CRUD)
Analog: apps/pwa/src/components/CalendarShell.tsx lines 1–60 (header shown above).
Data fetching pattern — useQuery with credentials (from CalendarShell + client.ts):
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { fetchLists, createList, deleteList } from '../api/listsClient.js'
const { data, isLoading, isError } = useQuery({
queryKey: ['lists'],
queryFn: fetchLists,
})
State branches — mirror CalendarShell: isLoading → skeleton/empty, isError → error state with retry, data → render list. CalendarShell uses isLoading / isError / success branches explicitly.
useMutation with optimistic update (from RESEARCH.md Finding 6):
const queryClient = useQueryClient()
const deleteMutation = useMutation({
mutationFn: (listId: number) => deleteList(listId),
onMutate: async (listId) => {
await queryClient.cancelQueries({ queryKey: ['lists'] })
const previous = queryClient.getQueryData(['lists'])
queryClient.setQueryData(['lists'], (old: any) => ({
...old,
lists: old.lists.filter((l: any) => l.id !== listId),
}))
return { previous }
},
onError: (_err, _vars, context) => {
if (context?.previous) queryClient.setQueryData(['lists'], context.previous)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['lists'] })
},
})
DeleteConfirmationDialog reuse — import from ../components/DeleteConfirmationDialog.js and render conditionally. The existing dialog is tightly coupled to calendarStore; create a new list-delete confirmation component (ListDeleteDialog) that mirrors its structure but is driven by listsStore. Do NOT modify the existing dialog — it is stable (D-06 says reuse, but the implementation is wired to calendarStore).
apps/pwa/src/routes/ListDetail.tsx (component, CRUD + event-driven)
Analog: apps/pwa/src/components/CalendarShell.tsx
SSE + polling pattern (from RESEARCH.md Finding 4):
import { useListSSE } from '../hooks/useListSSE.js'
const { data } = useQuery({
queryKey: ['list', listId],
queryFn: () => fetchListItems(listId),
refetchInterval: 30_000, // D-12: polling fallback always active
})
useListSSE({ listId, onStateChange: setSyncState })
Active / completed section split (D-05):
const activeItems = items.filter((i) => !i.checked).sort(/* by rank ASC */)
const completedItems = items.filter((i) => i.checked)
apps/pwa/src/components/DeleteConfirmationDialog.tsx (reuse — no modification)
This file is not modified. A new ListDeleteDialog.tsx mirrors its structure:
- Same modal layout: fixed backdrop + centered dialog
- Same CSS tokens:
var(--color-overlay),var(--color-surface-raised),var(--color-destructive),var(--space-*),var(--text-*),var(--font-family-base) - Same
useMutation+onSuccess→ close pattern (lines 64–76 of DeleteConfirmationDialog.tsx) - Same accessibility:
role="dialog",aria-modal="true", Escape key listener,tabIndex={-1}+ focus on open
Key lines to copy for the modal skeleton (lines 86–208 of DeleteConfirmationDialog.tsx) — swap the heading text to "Delete list?" and the body text to "All items in this list will be permanently deleted."
apps/pwa/src/components/ItemRow.tsx (component, event-driven)
Analog: apps/pwa/src/components/DeleteConfirmationDialog.tsx (for mutation + CSS token patterns)
dnd-kit drag handle pattern (from RESEARCH.md Finding 7):
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVertical } from 'lucide-react'
export function ItemRow({ item, onCheck, onDelete }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: item.id })
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition: transition ?? 'transform 150ms ease-out', // D-14: animate remote reorders
opacity: isDragging ? 0.8 : 1,
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
minHeight: '44px', // touch target
}}
{...attributes}
>
{/* Drag handle — listeners on handle only (not whole row) */}
<button
{...listeners}
aria-label="Drag to reorder"
style={{ background: 'none', border: 'none', cursor: 'grab', padding: 'var(--space-1)' }}
>
<GripVertical size={16} color="var(--color-text-secondary)" />
</button>
{/* ... checkbox, text, delete button */}
</div>
)
}
apps/pwa/src/hooks/useListSSE.ts (hook, event-driven)
No codebase analog. Use the pattern from RESEARCH.md Finding 4 verbatim. Key conventions:
useCallbackforconnectto keep theuseEffectdependency stableesRef,attemptsRef,timerRef— alluseRef(not state) to avoid re-render loops- Close
eson error before scheduling retry (prevents browser auto-reconnect stacking with manual reconnect) withCredentials: trueonnew EventSource(...)— required for session cookie (Pitfall 7)- Return
syncStateso the caller can renderLiveSyncIndicator
Shared Patterns
Auth / User Resolution
Source: apps/api/src/routes/events.ts lines 59–76
Apply to: apps/api/src/routes/lists.ts
async function resolveUserId(c: any): Promise<number | null> {
const devUser = c.get('user') as { id: number } | undefined
if (devUser) return devUser.id
const auth = await getAuth(c)
if (!auth) return null
const iss = (auth.iss as string | undefined) ?? ''
const sub = auth.sub ?? ''
const displayName = deriveDisplayName(auth)
const user = await upsertUser(iss, sub, displayName)
return user?.id ?? null
}
Copy verbatim — do not extract to a shared module (existing convention duplicates this per router).
Error Handling (API routes)
Source: apps/api/src/routes/events.ts (every handler's catch block)
Apply to: apps/api/src/routes/lists.ts
try {
// ... db operations ...
return c.json({ /* result */ })
} catch (err) {
console.error('[lists/<endpoint>] DB operation failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
}
Pattern: 503 on caught exceptions, not 500. console.error with a [module/endpoint] prefix tag.
401 Guard Pattern
Source: apps/api/src/routes/events.ts (every handler, lines 125–126):
const currentUserId = await resolveUserId(c)
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
First two lines of every protected handler.
CSS Token Usage (PWA components)
Source: apps/pwa/src/components/DeleteConfirmationDialog.tsx (all inline styles)
Apply to: all new PWA components
Token set in use:
var(--color-surface),var(--color-surface-raised),var(--color-overlay)var(--color-text-primary),var(--color-text-secondary)var(--color-destructive),var(--color-border)var(--space-1)throughvar(--space-6)var(--text-body-size),var(--text-heading-size),var(--text-label-size),var(--font-family-base)- Minimum touch target:
minHeight: '44px'(buttons/rows)
Fetch with Credentials (PWA API client)
Source: apps/pwa/src/api/client.ts lines 36–39
Apply to: apps/pwa/src/api/listsClient.ts
const res = await fetch('/api/...', {
credentials: 'include',
redirect: 'manual', // only for /api/me; not required for data endpoints
})
All list API calls use credentials: 'include'. redirect: 'manual' is only needed for the initial session check (/api/me) — not for list CRUD endpoints.
TanStack Query Keys
Apply to: apps/pwa/src/routes/ListsIndex.tsx, apps/pwa/src/routes/ListDetail.tsx
// List of lists
queryKey: ['lists']
// Items for a specific list
queryKey: ['list', listId] // listId is a number
Invalidate ['lists'] after create/delete list. Invalidate ['list', listId] after any item mutation or SSE event for that list.
Lucide Icons (PWA)
Source: apps/pwa/src/components/DeleteConfirmationDialog.tsx line 27, CalendarShell.tsx line 43
Apply to: all new PWA components
import { Trash2 } from 'lucide-react'
// Usage: <Trash2 size={16} aria-hidden="true" />
Always pass aria-hidden="true" to decorative icons. Use size={16} for inline/dense contexts, size={22} for navigation tabs.
Zustand Store Shape
Source: apps/pwa/src/store/calendarStore.ts
Apply to: apps/pwa/src/store/listsStore.ts
- Use
create<StoreInterface>()((set) => ({ ... }))— nopersist, noimmer - Actions are inline setter functions, not separate files
- UI-only state: no server data, no async in store actions (mutations live in components via
useMutation)
Plain-text XSS Guard (PWA)
Source: apps/pwa/src/components/DeleteConfirmationDialog.tsx (comment /* Plain text — XSS guard */ on every text node, line 133 etc.)
Apply to: all new PWA components that render user-supplied strings (list names, item text)
Never use dangerouslySetInnerHTML. All user content is a JSX text child.
No Analog Found
| File | Role | Data Flow | Reason |
|---|---|---|---|
apps/api/src/lib/listEmitter.ts |
utility | event-driven | No EventEmitter or pub/sub pattern exists in the codebase. Use RESEARCH.md Finding 1 pattern. |
apps/pwa/src/components/LiveSyncIndicator.tsx |
component | event-driven | No live-sync state indicator exists. Novel UI component — use CSS token conventions and the "disconnected / updates paused" UX from D-11. |
apps/pwa/src/hooks/useListSSE.ts |
hook | event-driven | No custom hooks exist in the codebase. Novel — use RESEARCH.md Finding 4 pattern. |
Metadata
Analog search scope: apps/api/src/routes/, apps/api/src/db/, apps/api/src/lib/, apps/api/src/index.ts, apps/pwa/src/, apps/pwa/src/components/, apps/pwa/src/api/, apps/pwa/src/store/
Files scanned: 12 source files read in full
Pattern extraction date: 2026-06-09