slice 10a WatchlistRepository (ornith-35): addSymbol/removeSymbol/listSymbols, parameterized, idempotent, ADR-0007

Cross-review by qwopus35b pending.
This commit is contained in:
Investor Flow Build
2026-06-30 13:22:02 -04:00
parent cfc762e952
commit 343d418405
+220
View File
@@ -0,0 +1,220 @@
// Investor Flow — Watchlist Repository (Slice 10: watchlist-portfolio-shell-panels)
//
// Thin data-access layer over the `watchlists` table. Read/write only — no trade verbs
// per ADR-0007 (Primary-Rule: no imperative-trade-verb in any string). Notes are the
// user's own neutral text, never generated by the system.
//
// Schema (schema.sql):
// CREATE TABLE IF NOT EXISTS watchlists (
// id TEXT PRIMARY KEY,
// owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
// name TEXT NOT NULL,
// symbols TEXT NOT NULL, -- JSON array of symbol strings
// created_at TEXT NOT NULL,
// sort_order INTEGER NOT NULL DEFAULT 0
// );
import type { DatabaseSync } from 'node:sqlite';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A single watchlist entry returned by listSymbols. */
export interface WatchlistEntry {
symbol: string;
added_at: string;
notes?: string | null;
}
/** A full watchlist row (internal). */
interface WatchlistRow {
id: string;
owner_id: string;
name: string;
symbols: string[];
created_at: string;
sort_order: number;
}
// ---------------------------------------------------------------------------
// Prepared statements (lazy, one per method)
// ---------------------------------------------------------------------------
function stmts(db: DatabaseSync) {
return {
/** Upsert a watchlist row (idempotent by owner_id + name). */
upsert: db.prepare(
`INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order)
VALUES (?, ?, ?, ?, ?, COALESCE(?, 0))
ON CONFLICT(owner_id, name) DO UPDATE SET
symbols = excluded.symbols,
sort_order = excluded.sort_order`,
),
/** Select a single watchlist by owner+name. */
selectByOwnerAndName: db.prepare(
`SELECT id, owner_id, name, symbols, created_at, sort_order
FROM watchlists WHERE owner_id = ? AND name = ?`,
),
/** Select all watchlists for a user. */
selectByOwner: db.prepare(
`SELECT id, owner_id, name, symbols, created_at, sort_order
FROM watchlists WHERE owner_id = ?
ORDER BY sort_order ASC, created_at ASC`,
),
/** Delete a watchlist by owner+name. */
deleteByOwnerAndName: db.prepare(
`DELETE FROM watchlists WHERE owner_id = ? AND name = ?`,
),
/** Update just the symbols array. */
updateSymbols: db.prepare(
`UPDATE watchlists SET symbols = ? WHERE id = ? AND owner_id = ?`,
),
};
}
// ---------------------------------------------------------------------------
// Repository — public API (all methods parameterized, no string interpolation)
// ---------------------------------------------------------------------------
/**
* Add a symbol to the user's default watchlist. Idempotent — adding an already-
* present symbol is a no-op. Returns true if the symbol was newly added.
*
* Per ADR-0007, notes are the user's own neutral text; the system never generates
* directional/trade-verb language.
*/
export function addSymbol(
db: DatabaseSync,
userId: string,
symbol: string,
notes?: string,
): boolean {
const s = stmts(db);
const upper = symbol.toUpperCase();
// Read existing default watchlist.
const existing = readDefaultWatchlist(db, userId);
const symbols: string[] = existing?.symbols ?? [];
if (symbols.includes(upper)) {
return false; // already present — no-op
}
symbols.push(upper);
// Serialize: if this is the just-added symbol with notes, embed them.
const serialized = symbols.map((s) => {
if (s === upper && notes) {
return JSON.stringify({ symbol: s, notes });
}
return JSON.stringify(s);
});
const id = existing?.id ?? generateId();
const now = new Date().toISOString();
// Upsert: insert-or-replace by (owner_id, name).
s.upsert.run(id, userId, 'default', JSON.stringify(serialized), now, 0);
return true;
}
/** Remove a symbol from the user's default watchlist. Returns true if removed. */
export function removeSymbol(
db: DatabaseSync,
userId: string,
symbol: string,
): boolean {
const s = stmts(db);
const upper = symbol.toUpperCase();
const existing = readDefaultWatchlist(db, userId);
if (!existing) return false;
const before = existing.symbols.length;
const remaining = existing.symbols.filter((s) => s !== upper);
if (remaining.length === before) {
return false; // symbol not found
}
if (remaining.length === 0) {
// Clean up empty watchlist.
s.deleteByOwnerAndName.run(userId, 'default');
return true;
}
const serialized = remaining.map((sym) => JSON.stringify(sym));
s.updateSymbols.run(JSON.stringify(serialized), existing.id, userId);
return true;
}
/** List all symbols across all watchlists for a user. */
export function listSymbols(
db: DatabaseSync,
userId: string,
): WatchlistEntry[] {
const s = stmts(db);
const rows = s.selectByOwner.all(userId) as unknown as Array<{ symbols: string }>;
const entries: WatchlistEntry[] = [];
for (const row of rows) {
const parsed = safeParseSymbols(row.symbols);
for (const item of parsed) {
if (typeof item === 'string') {
entries.push({ symbol: item, added_at: '' });
} else if (typeof item === 'object' && item !== null) {
const obj = item as { symbol?: string; notes?: string };
entries.push({
symbol: (obj.symbol ?? '').toUpperCase(),
notes: obj.notes ?? null,
added_at: '',
});
}
}
}
return entries;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Safely parse the JSON TEXT column into an array of strings or objects. */
function safeParseSymbols(value: string | null | undefined): (string | Record<string, unknown>)[] {
if (!value) return [];
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
return parsed;
}
} catch {
/* ignore parse errors */
}
return [];
}
/** Read the default watchlist for a user. Returns null if not found. */
function readDefaultWatchlist(db: DatabaseSync, userId: string): WatchlistRow | null {
const rows = stmts(db).selectByOwnerAndName.all(userId, 'default') as unknown as WatchlistRow[];
if (rows.length === 0) return null;
const row = rows[0];
return {
...row,
symbols: safeParseSymbols(String(row.symbols)).filter((s): s is string => typeof s === 'string') as string[],
};
}
/** Generate a simple unique id. */
function generateId(): string {
return `wl_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}