phase-9: domain-driven architecture complete
- Restructured server layer with 5 domains: shared, screener, portfolio, calls, finance - Migrated 58 TypeScript files to domain-driven structure - Updated CLAUDE.md with new architecture documentation - Added .gitignore rules for .md files (except CLAUDE.md) - Removed unused CatalystAnalyst import from app.ts - Fixed lint errors: removed unused imports, fixed regex escape, added console suppressions - Verified no sensitive data in git history - Server code compiles cleanly with TypeScript strict mode
This commit is contained in:
committed by
saikiranvella
parent
c7e39c3e4e
commit
c388b6d83c
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* DatabaseConnection — High-level database abstraction.
|
||||
*
|
||||
* Wraps better-sqlite3 with:
|
||||
* - QueryBuilder for type-safe, injection-proof queries
|
||||
* - QueryAudit for logging and compliance
|
||||
* - Statement caching for performance
|
||||
* - Transaction support
|
||||
*
|
||||
* Usage:
|
||||
* const db = new DatabaseConnection(betterSqlite3Db, options);
|
||||
* const qb = new QueryBuilder('holdings').select(['ticker', 'shares']).where('type = ?', ['stock']);
|
||||
* const rows = db.all(qb);
|
||||
* const row = db.get(qb);
|
||||
* db.run(qb);
|
||||
*/
|
||||
|
||||
import type BetterSqlite3 from 'better-sqlite3';
|
||||
import type { DatabaseOptions } from '../types/index';
|
||||
import { AuditAction } from '../types/index';
|
||||
import { QueryBuilder } from '../utils/QueryBuilder';
|
||||
import { QueryAudit } from './QueryAudit';
|
||||
|
||||
/**
|
||||
* DatabaseConnection — Safe, auditable, performant SQLite wrapper.
|
||||
*/
|
||||
export class DatabaseConnection {
|
||||
private db: BetterSqlite3.Database;
|
||||
private audit: QueryAudit;
|
||||
private logSlowQueries: number;
|
||||
private statementCache = new Map<string, BetterSqlite3.Statement>();
|
||||
|
||||
constructor(db: BetterSqlite3.Database, options: DatabaseOptions = {}) {
|
||||
this.db = db;
|
||||
this.audit = options.audit ?? new QueryAudit();
|
||||
this.logSlowQueries = options.logSlowQueries ?? 100; // 100ms default
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a SELECT query and return all rows.
|
||||
* Logs the query to the audit trail.
|
||||
*/
|
||||
all<T = Record<string, unknown>>(qb: QueryBuilder): T[] {
|
||||
const sql = qb.sql;
|
||||
const params = qb.queryParams;
|
||||
const startMs = performance.now();
|
||||
|
||||
try {
|
||||
const stmt = this.getOrCacheStatement(sql);
|
||||
const rows = stmt.all(...params) as T[];
|
||||
|
||||
const durationMs = performance.now() - startMs;
|
||||
this.audit.log(sql, params, AuditAction.READ, durationMs, rows.length);
|
||||
this.logIfSlow(sql, durationMs);
|
||||
|
||||
return rows;
|
||||
} catch (err) {
|
||||
const durationMs = performance.now() - startMs;
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
this.audit.log(sql, params, AuditAction.READ, durationMs, undefined, errorMsg);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a SELECT query and return the first row only.
|
||||
* Returns null if no rows match.
|
||||
* Logs the query to the audit trail.
|
||||
*/
|
||||
get<T = Record<string, unknown>>(qb: QueryBuilder): T | null {
|
||||
const sql = qb.sql;
|
||||
const params = qb.queryParams;
|
||||
const startMs = performance.now();
|
||||
|
||||
try {
|
||||
const stmt = this.getOrCacheStatement(sql);
|
||||
const row = stmt.get(...params) as T | undefined;
|
||||
|
||||
const durationMs = performance.now() - startMs;
|
||||
this.audit.log(sql, params, AuditAction.READ, durationMs, row ? 1 : 0);
|
||||
this.logIfSlow(sql, durationMs);
|
||||
|
||||
return row ?? null;
|
||||
} catch (err) {
|
||||
const durationMs = performance.now() - startMs;
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
this.audit.log(sql, params, AuditAction.READ, durationMs, undefined, errorMsg);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an INSERT, UPDATE, or DELETE query.
|
||||
* Returns the number of rows affected.
|
||||
* Logs the query to the audit trail.
|
||||
*/
|
||||
run(qb: QueryBuilder): number {
|
||||
const sql = qb.sql;
|
||||
const params = qb.queryParams;
|
||||
const startMs = performance.now();
|
||||
|
||||
// Determine audit action from SQL
|
||||
const sqlUpper = sql.toUpperCase().trim();
|
||||
const action = sqlUpper.startsWith('DELETE')
|
||||
? AuditAction.DELETE
|
||||
: sqlUpper.startsWith('INSERT')
|
||||
? AuditAction.WRITE
|
||||
: AuditAction.WRITE;
|
||||
|
||||
try {
|
||||
const stmt = this.getOrCacheStatement(sql);
|
||||
const result = stmt.run(...params);
|
||||
|
||||
const durationMs = performance.now() - startMs;
|
||||
this.audit.log(sql, params, action, durationMs, result.changes);
|
||||
this.logIfSlow(sql, durationMs);
|
||||
|
||||
return result.changes;
|
||||
} catch (err) {
|
||||
const durationMs = performance.now() - startMs;
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
this.audit.log(sql, params, action, durationMs, 0, errorMsg);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a transaction — multiple queries as an atomic unit.
|
||||
* All queries must succeed, or all are rolled back.
|
||||
*
|
||||
* Usage:
|
||||
* db.transaction(() => {
|
||||
* db.run(qb1);
|
||||
* db.run(qb2);
|
||||
* });
|
||||
*/
|
||||
transaction<T>(fn: () => T): T {
|
||||
const txn = this.db.transaction(fn);
|
||||
return txn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw better-sqlite3 Db instance (for advanced use only).
|
||||
* Prefer the DatabaseConnection methods.
|
||||
*/
|
||||
raw(): BetterSqlite3.Database {
|
||||
return this.db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the audit trail instance.
|
||||
*/
|
||||
getAudit(): QueryAudit {
|
||||
return this.audit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the statement cache (for testing or extreme memory pressure).
|
||||
*/
|
||||
clearStatementCache(): void {
|
||||
this.statementCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the audit trail instance.
|
||||
* Call db.printAudit() to see the most recent 100 queries.
|
||||
*/
|
||||
printAudit(): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(this.audit.report());
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get or create a cached prepared statement.
|
||||
* Reduces compilation overhead for frequently-run queries.
|
||||
*/
|
||||
private getOrCacheStatement(sql: string): BetterSqlite3.Statement {
|
||||
let stmt = this.statementCache.get(sql);
|
||||
if (!stmt) {
|
||||
stmt = this.db.prepare(sql);
|
||||
this.statementCache.set(sql, stmt);
|
||||
}
|
||||
return stmt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log slow queries to console.
|
||||
*/
|
||||
private logIfSlow(sql: string, durationMs: number): void {
|
||||
if (durationMs > this.logSlowQueries) {
|
||||
console.warn(`[SLOW QUERY] ${durationMs.toFixed(2)}ms\n ${sql}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Database initialization and migration.
|
||||
*
|
||||
* Handles:
|
||||
* - Creating/opening SQLite database
|
||||
* - Running DDL schema setup
|
||||
* - Migrating legacy JSON files (one-time)
|
||||
*/
|
||||
|
||||
import BetterSqlite3 from 'better-sqlite3';
|
||||
import { existsSync, readFileSync, renameSync } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { DDL } from './queries.constant';
|
||||
import { QueryBuilder } from '../utils/QueryBuilder';
|
||||
|
||||
export type Db = BetterSqlite3.Database;
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface LegacyHolding {
|
||||
ticker: string;
|
||||
shares: number;
|
||||
costBasis: number;
|
||||
type: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface LegacyCall {
|
||||
id?: string;
|
||||
title: string;
|
||||
quarter: string;
|
||||
date: string;
|
||||
thesis: string;
|
||||
tickers: string[];
|
||||
snapshot: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ── Main Export ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initialize and open the SQLite database.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Create/open database file
|
||||
* 2. Enable WAL mode (concurrent read safety)
|
||||
* 3. Enable foreign keys
|
||||
* 4. Run DDL (create tables if missing)
|
||||
* 5. Migrate legacy JSON files (one-time)
|
||||
*
|
||||
* @param path Path to database file (default: ./market-screener.db)
|
||||
* @returns Opened database instance (wrap in DatabaseConnection for safe access)
|
||||
*/
|
||||
export function createDb(path = './market-screener.db'): Db {
|
||||
const db = new BetterSqlite3(path);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(DDL);
|
||||
migrateJson(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
// ── Migration Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Migrate legacy JSON files to SQLite (one-time, non-fatal).
|
||||
* Called automatically during database initialization.
|
||||
*/
|
||||
function migrateJson(db: Db): void {
|
||||
migratePortfolio(db);
|
||||
migrateCalls(db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate portfolio.json → holdings table.
|
||||
* If portfolio.json exists, import all holdings and rename to portfolio.json.migrated.
|
||||
* If import fails, leave portfolio.json in place (non-fatal).
|
||||
*/
|
||||
function migratePortfolio(db: Db): void {
|
||||
const src = './portfolio.json';
|
||||
if (!existsSync(src)) return;
|
||||
|
||||
try {
|
||||
const { holdings } = JSON.parse(readFileSync(src, 'utf8')) as {
|
||||
holdings: LegacyHolding[];
|
||||
};
|
||||
|
||||
const insertAll = db.transaction((rows: LegacyHolding[]) => {
|
||||
for (const h of rows) {
|
||||
const qb = new QueryBuilder('MIGRATION_QUERIES.HOLDINGS_INSERT_OR_IGNORE', [
|
||||
h.ticker.toUpperCase(),
|
||||
h.shares,
|
||||
h.costBasis ?? 0,
|
||||
h.type ?? 'stock',
|
||||
h.source ?? 'Manual',
|
||||
]);
|
||||
db.prepare(qb.sql).run(...qb.queryParams);
|
||||
}
|
||||
});
|
||||
|
||||
insertAll(holdings);
|
||||
renameSync(src, `${src}.migrated`);
|
||||
} catch {
|
||||
// Non-fatal: leave portfolio.json in place if migration fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate market-calls.json → market_calls table.
|
||||
* If market-calls.json exists, import all calls and rename to market-calls.json.migrated.
|
||||
* If import fails, leave market-calls.json in place (non-fatal).
|
||||
*/
|
||||
function migrateCalls(db: Db): void {
|
||||
const src = './market-calls.json';
|
||||
if (!existsSync(src)) return;
|
||||
|
||||
try {
|
||||
const { calls } = JSON.parse(readFileSync(src, 'utf8')) as {
|
||||
calls: LegacyCall[];
|
||||
};
|
||||
|
||||
const insertAll = db.transaction((rows: LegacyCall[]) => {
|
||||
for (const c of rows) {
|
||||
const qb = new QueryBuilder('MIGRATION_QUERIES.MARKET_CALLS_INSERT_OR_IGNORE', [
|
||||
c.id ?? randomUUID(),
|
||||
c.title,
|
||||
c.quarter,
|
||||
c.date,
|
||||
c.thesis,
|
||||
JSON.stringify(c.tickers ?? []),
|
||||
JSON.stringify(c.snapshot ?? {}),
|
||||
c.createdAt,
|
||||
]);
|
||||
db.prepare(qb.sql).run(...qb.queryParams);
|
||||
}
|
||||
});
|
||||
|
||||
insertAll(calls);
|
||||
renameSync(src, `${src}.migrated`);
|
||||
} catch {
|
||||
// Non-fatal: leave market-calls.json in place if migration fails
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Query audit logging — tracks all database mutations.
|
||||
*
|
||||
* Usage:
|
||||
* const audit = new QueryAudit();
|
||||
* audit.log('SELECT * FROM holdings', [], AuditAction.READ, 1.5);
|
||||
* audit.log('UPDATE holdings SET shares = ? WHERE ticker = ?', [100, 'AAPL'], AuditAction.WRITE, 0.8, 1);
|
||||
*
|
||||
* Provides:
|
||||
* - Audit trail of all queries executed
|
||||
* - Timing information (for performance monitoring)
|
||||
* - Clear distinction between READ/WRITE operations
|
||||
* - Optional persistent storage for compliance
|
||||
*/
|
||||
|
||||
import type { AuditAction, AuditEntry } from '../types/index';
|
||||
|
||||
/**
|
||||
* QueryAudit — in-memory audit trail with optional callbacks.
|
||||
*/
|
||||
export class QueryAudit {
|
||||
private entries: AuditEntry[] = [];
|
||||
private onLog?: (entry: AuditEntry) => void | Promise<void>;
|
||||
|
||||
constructor(onLog?: (entry: AuditEntry) => void | Promise<void>) {
|
||||
this.onLog = onLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a query execution.
|
||||
* @param sql The SQL string (with ? placeholders intact)
|
||||
* @param params The parameter array (safe to log; no raw values in SQL)
|
||||
* @param action The operation type (READ, WRITE, DELETE)
|
||||
* @param durationMs Execution time in milliseconds
|
||||
* @param rowsAffected Number of rows affected (for INSERT/UPDATE/DELETE)
|
||||
* @param error If execution failed, the error message
|
||||
*/
|
||||
log(
|
||||
sql: string,
|
||||
params: unknown[],
|
||||
action: AuditAction,
|
||||
durationMs: number,
|
||||
rowsAffected?: number,
|
||||
error?: string,
|
||||
): void {
|
||||
const entry: AuditEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
action,
|
||||
sql,
|
||||
params,
|
||||
durationMs,
|
||||
rowsAffected,
|
||||
error,
|
||||
};
|
||||
|
||||
this.entries.push(entry);
|
||||
|
||||
// Call the optional callback (could write to file, logger, or remote service)
|
||||
if (this.onLog) {
|
||||
const result = this.onLog(entry);
|
||||
if (result instanceof Promise) {
|
||||
result.catch((err) => {
|
||||
console.error('QueryAudit callback failed:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all audit entries.
|
||||
*/
|
||||
all(): AuditEntry[] {
|
||||
return [...this.entries];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter audit entries by action type.
|
||||
*/
|
||||
byAction(action: AuditAction): AuditEntry[] {
|
||||
return this.entries.filter((e) => e.action === action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent N entries.
|
||||
*/
|
||||
recent(count: number = 100): AuditEntry[] {
|
||||
return this.entries.slice(-count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the audit trail.
|
||||
* (Typically not needed unless for testing or cleanup.)
|
||||
*/
|
||||
clear(): void {
|
||||
this.entries = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a human-readable audit report.
|
||||
*/
|
||||
report(limitEntries: number = 100): string {
|
||||
const recent = this.recent(limitEntries);
|
||||
let report = `\n=== Query Audit Report ===\n`;
|
||||
report += `Total entries: ${this.entries.length}\n`;
|
||||
report += `Showing last ${recent.length} entries:\n\n`;
|
||||
|
||||
for (const entry of recent) {
|
||||
report += `[${entry.timestamp}] ${entry.action}`;
|
||||
if (entry.error) {
|
||||
report += ` ❌ (${entry.error})`;
|
||||
} else {
|
||||
report += ` ✓ (${entry.durationMs}ms)`;
|
||||
if (entry.rowsAffected !== undefined) {
|
||||
report += ` — ${entry.rowsAffected} rows`;
|
||||
}
|
||||
}
|
||||
report += `\n SQL: ${entry.sql}\n`;
|
||||
if (entry.params.length > 0) {
|
||||
report += ` Params: [${entry.params.map((p) => JSON.stringify(p)).join(', ')}]\n`;
|
||||
}
|
||||
report += '\n';
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Database layer — barrel export (ONLY re-exports, no logic).
|
||||
*
|
||||
* This file is the SINGLE public API for all database functionality.
|
||||
* All imports should come from here, not from individual files.
|
||||
*
|
||||
* USAGE:
|
||||
* import { createDb, DatabaseConnection, QueryAudit } from './db/index.js';
|
||||
* import type { AuditEntry } from './db/index.js';
|
||||
*
|
||||
* FILE ORGANIZATION:
|
||||
* - DatabaseInitializer.ts: createDb() function + migrations (pure functions)
|
||||
* - QueryAudit.ts: class QueryAudit (logging service)
|
||||
* - DatabaseConnection.ts: class DatabaseConnection (data access service)
|
||||
* - index.ts: THIS FILE (barrel re-exports only)
|
||||
*
|
||||
* SECURITY:
|
||||
* - All queries use parameterized statements (QueryBuilder + DatabaseConnection)
|
||||
* - No SQL injection possible via table/column/parameter names
|
||||
* - Audit trail tracks all mutations for compliance
|
||||
*/
|
||||
|
||||
// Initialization
|
||||
export { createDb, type Db } from './DatabaseInitializer';
|
||||
|
||||
// Data access
|
||||
export { DatabaseConnection } from './DatabaseConnection';
|
||||
export { QueryAudit } from './QueryAudit';
|
||||
|
||||
// Types
|
||||
export { AuditAction } from '../types/database.model';
|
||||
export type { AuditEntry, DatabaseOptions } from '../types/database.model';
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* SQL Query Constants
|
||||
*
|
||||
* All SQL queries used in the application.
|
||||
* Repositories reference these by name (e.g., MARKET_CALLS_QUERIES.SELECT_ALL).
|
||||
* QueryBuilder looks them up and binds parameters.
|
||||
*
|
||||
* All queries use parameterized statements (?) for security.
|
||||
* User input NEVER goes into the SQL string.
|
||||
*/
|
||||
|
||||
// ── Holdings Table Queries ───────────────────────────────────────────────────
|
||||
|
||||
export const HOLDINGS_QUERIES = {
|
||||
// Check if any holdings exist
|
||||
EXISTS: 'SELECT COUNT(*) AS n FROM holdings',
|
||||
|
||||
// Get all holdings, sorted by ticker
|
||||
SELECT_ALL: 'SELECT ticker, shares, cost_basis, type, source FROM holdings ORDER BY ticker ASC',
|
||||
|
||||
// Insert or update a holding (UPSERT)
|
||||
UPSERT: `
|
||||
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
|
||||
`,
|
||||
|
||||
// Delete a holding by ticker
|
||||
DELETE_BY_TICKER: 'DELETE FROM holdings WHERE ticker = ?',
|
||||
};
|
||||
|
||||
// ── Market Calls Table Queries ───────────────────────────────────────────────
|
||||
|
||||
export const MARKET_CALLS_QUERIES = {
|
||||
// Get all market calls, newest first
|
||||
SELECT_ALL: `
|
||||
SELECT id, title, quarter, date, thesis, tickers, snapshot, created_at
|
||||
FROM market_calls
|
||||
ORDER BY created_at DESC
|
||||
`,
|
||||
|
||||
// Get a single market call by ID
|
||||
SELECT_BY_ID: `
|
||||
SELECT id, title, quarter, date, thesis, tickers, snapshot, created_at
|
||||
FROM market_calls
|
||||
WHERE id = ?
|
||||
`,
|
||||
|
||||
// Insert a new market call
|
||||
INSERT: `
|
||||
INSERT INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
|
||||
// Delete a market call by ID
|
||||
DELETE_BY_ID: 'DELETE FROM market_calls WHERE id = ?',
|
||||
};
|
||||
|
||||
// ── Migration Queries (for DatabaseInitializer) ──────────────────────────────
|
||||
|
||||
export const MIGRATION_QUERIES = {
|
||||
// Insert holdings during migration
|
||||
HOLDINGS_INSERT_OR_IGNORE: `
|
||||
INSERT OR IGNORE INTO holdings (ticker, shares, cost_basis, type, source)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`,
|
||||
|
||||
// Insert market calls during migration
|
||||
MARKET_CALLS_INSERT_OR_IGNORE: `
|
||||
INSERT OR IGNORE INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
};
|
||||
|
||||
// ── Schema Definition (DDL) ──────────────────────────────────────────────────
|
||||
|
||||
export const DDL = `
|
||||
CREATE TABLE IF NOT EXISTS holdings (
|
||||
ticker TEXT PRIMARY KEY,
|
||||
shares REAL NOT NULL,
|
||||
cost_basis REAL NOT NULL DEFAULT 0,
|
||||
type TEXT NOT NULL DEFAULT 'stock',
|
||||
source TEXT NOT NULL DEFAULT 'Manual'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS market_calls (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
quarter TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
thesis TEXT NOT NULL,
|
||||
tickers TEXT NOT NULL, -- JSON array
|
||||
snapshot TEXT NOT NULL, -- JSON object
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
Reference in New Issue
Block a user