phase-8g: add sqllite.
This commit is contained in:
@@ -1,37 +1,33 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { MarketCall, CreateCallInput, StoreData } from '../types';
|
||||
import type { Db } from '../db/index';
|
||||
import type { MarketCall, CreateCallInput } from '../types';
|
||||
|
||||
interface CallRow {
|
||||
id: string;
|
||||
title: string;
|
||||
quarter: string;
|
||||
date: string;
|
||||
thesis: string;
|
||||
tickers: string; // JSON
|
||||
snapshot: string; // JSON
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export class MarketCallRepository {
|
||||
private static readonly DEFAULT_PATH = './market-calls.json';
|
||||
|
||||
private readonly storePath: string;
|
||||
|
||||
constructor(storePath?: string) {
|
||||
this.storePath = storePath ?? MarketCallRepository.DEFAULT_PATH;
|
||||
}
|
||||
|
||||
private load(): StoreData {
|
||||
if (!existsSync(this.storePath)) return { calls: [] };
|
||||
try {
|
||||
return JSON.parse(readFileSync(this.storePath, 'utf8')) as StoreData;
|
||||
} catch {
|
||||
return { calls: [] };
|
||||
}
|
||||
}
|
||||
|
||||
private save(data: StoreData): void {
|
||||
writeFileSync(this.storePath, JSON.stringify(data, null, 2), 'utf8');
|
||||
}
|
||||
constructor(private readonly db: Db) {}
|
||||
|
||||
list(): (MarketCall & { createdAt: string })[] {
|
||||
return this.load().calls.sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
const rows = this.db
|
||||
.prepare('SELECT * FROM market_calls ORDER BY created_at DESC')
|
||||
.all() as CallRow[];
|
||||
return rows.map(MarketCallRepository.toCall);
|
||||
}
|
||||
|
||||
get(id: string): (MarketCall & { createdAt: string }) | null {
|
||||
return this.load().calls.find((c) => c.id === id) ?? null;
|
||||
const row = this.db.prepare('SELECT * FROM market_calls WHERE id = ?').get(id) as
|
||||
| CallRow
|
||||
| undefined;
|
||||
return row ? MarketCallRepository.toCall(row) : null;
|
||||
}
|
||||
|
||||
create({
|
||||
@@ -42,28 +38,49 @@ export class MarketCallRepository {
|
||||
tickers,
|
||||
snapshot,
|
||||
}: CreateCallInput): MarketCall & { createdAt: string } {
|
||||
const data = this.load();
|
||||
const call = {
|
||||
id: randomUUID(),
|
||||
title,
|
||||
quarter,
|
||||
date: date ?? new Date().toISOString().slice(0, 10),
|
||||
thesis,
|
||||
tickers,
|
||||
tickers: tickers ?? [],
|
||||
snapshot: snapshot ?? {},
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
data.calls.push(call);
|
||||
this.save(data);
|
||||
return call;
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
call.id,
|
||||
call.title,
|
||||
call.quarter,
|
||||
call.date,
|
||||
call.thesis,
|
||||
JSON.stringify(call.tickers),
|
||||
JSON.stringify(call.snapshot),
|
||||
call.createdAt,
|
||||
);
|
||||
return call as MarketCall & { createdAt: string };
|
||||
}
|
||||
|
||||
delete(id: string): boolean {
|
||||
const data = this.load();
|
||||
const before = data.calls.length;
|
||||
data.calls = data.calls.filter((c) => c.id !== id);
|
||||
if (data.calls.length === before) return false;
|
||||
this.save(data);
|
||||
return true;
|
||||
const result = this.db.prepare('DELETE FROM market_calls WHERE id = ?').run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
private static toCall(row: CallRow): MarketCall & { createdAt: string } {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
quarter: row.quarter,
|
||||
date: row.date,
|
||||
thesis: row.thesis,
|
||||
tickers: JSON.parse(row.tickers),
|
||||
snapshot: JSON.parse(row.snapshot),
|
||||
createdAt: row.created_at,
|
||||
} as MarketCall & { createdAt: string };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,63 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import type { Db } from '../db/index';
|
||||
import type { PortfolioData, PortfolioHolding } from '../types';
|
||||
|
||||
interface HoldingRow {
|
||||
ticker: string;
|
||||
shares: number;
|
||||
cost_basis: number;
|
||||
type: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export class PortfolioRepository {
|
||||
private static readonly PORTFOLIO_PATH = './portfolio.json';
|
||||
constructor(private readonly db: Db) {}
|
||||
|
||||
exists(): boolean {
|
||||
return existsSync(PortfolioRepository.PORTFOLIO_PATH);
|
||||
const row = this.db.prepare('SELECT COUNT(*) AS n FROM holdings').get() as { n: number };
|
||||
return row.n > 0;
|
||||
}
|
||||
|
||||
read(): PortfolioData {
|
||||
if (!this.exists()) return { holdings: [] };
|
||||
return JSON.parse(readFileSync(PortfolioRepository.PORTFOLIO_PATH, 'utf8')) as PortfolioData;
|
||||
}
|
||||
|
||||
write(data: PortfolioData): void {
|
||||
writeFileSync(PortfolioRepository.PORTFOLIO_PATH, JSON.stringify(data, null, 2), 'utf8');
|
||||
const rows = this.db.prepare('SELECT * FROM holdings ORDER BY ticker').all() as HoldingRow[];
|
||||
return { holdings: rows.map(PortfolioRepository.toHolding) };
|
||||
}
|
||||
|
||||
upsert(entry: PortfolioHolding): PortfolioHolding {
|
||||
const data = this.read();
|
||||
const normalized = entry.ticker.toUpperCase().trim();
|
||||
const idx = data.holdings.findIndex((h) => h.ticker.toUpperCase() === normalized);
|
||||
const record: PortfolioHolding = { ...entry, ticker: normalized };
|
||||
if (idx >= 0) data.holdings[idx] = record;
|
||||
else data.holdings.push(record);
|
||||
this.write(data);
|
||||
return record;
|
||||
const ticker = entry.ticker.toUpperCase().trim();
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO holdings (ticker, shares, cost_basis, type, source)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(ticker) DO UPDATE SET
|
||||
shares = excluded.shares,
|
||||
cost_basis = excluded.cost_basis,
|
||||
type = excluded.type,
|
||||
source = excluded.source`,
|
||||
)
|
||||
.run(
|
||||
ticker,
|
||||
entry.shares,
|
||||
entry.costBasis ?? 0,
|
||||
entry.type ?? 'stock',
|
||||
entry.source ?? 'Manual',
|
||||
);
|
||||
return { ...entry, ticker };
|
||||
}
|
||||
|
||||
remove(ticker: string): boolean {
|
||||
const data = this.read();
|
||||
const before = data.holdings.length;
|
||||
data.holdings = data.holdings.filter((h) => h.ticker.toUpperCase() !== ticker.toUpperCase());
|
||||
if (data.holdings.length === before) return false;
|
||||
this.write(data);
|
||||
return true;
|
||||
const result = this.db
|
||||
.prepare('DELETE FROM holdings WHERE ticker = ?')
|
||||
.run(ticker.toUpperCase());
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
private static toHolding(row: HoldingRow): PortfolioHolding {
|
||||
return {
|
||||
ticker: row.ticker,
|
||||
shares: row.shares,
|
||||
costBasis: row.cost_basis,
|
||||
type: row.type as PortfolioHolding['type'],
|
||||
source: row.source,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user