/** * Persistent storage for analytics data using SQLite */ import type { AnalyticsEntry, AnalyticsStorage } from './analytics-types.js'; import { type AnalyticsRollup } from './analytics-rollup.js'; /** * SQLite-backed analytics storage */ export declare class SqliteAnalyticsStorage implements AnalyticsStorage { private db; private batchQueue; private batchTimer; private readonly BATCH_SIZE; private readonly BATCH_DELAY_MS; private sinceLastPrune; private prunedOnce; private pruning; private readonly PRUNE_EVERY_SAVES; /** * The clock the automatic prune reads. * * INJECTABLE BECAUSE THE WALL CLOCK MADE THIS STORE UNTESTABLE. The prune * that runs after a write took `new Date()` while `pruneOldEntries(now)` * takes an argument, so a caller could inject a date for the explicit prune * and still have the automatic one fold rows out from under it against the * real clock. That is exactly what happened: a fixture dated 2026-09-01 was * folded by the post-write prune the moment the real date passed the * retention window, and the test's own `pruneOldEntries(2027-01-01)` then * found nothing and returned 0 -- a failure that appeared on four Node * shards roughly a month after the fixture was written and had nothing to do * with folding. */ private readonly clock; constructor(dbPath?: string, clock?: () => Date); /** * Initialize database schema */ /** * Column names an existing table has, or none if it does not exist. */ private columnsOf; /** * Moves an old-shaped rollup table aside so the creator below can build the * current one, and returns the columns it held. * * RENAME RATHER THAN A SECOND DDL. The measurement contract a row was * recorded under became part of the fold key, and SQLite cannot add a column * to an existing primary key -- the table has to be rebuilt. Renaming it out * of the way first means the rebuild uses the one `CREATE TABLE` statement * this file already has, instead of a copy of that shape kept in step by * hand, which is how a migrated store ends up subtly unlike a fresh one. */ private setAsideOldRollup; /** * Copies the set-aside rows into the rebuilt table and drops the old one. * * The rows carry no stamp, so they land under version 0 -- which is the * label, not a default. A folded day cannot be re-derived, so a row written * before the stamp existed can only ever say "an older contract produced * this", and that is what a reader needs it to say. */ /** * WHY eligible_operations CARRIES `DEFAULT 0` IN THE SCHEMA ABOVE. * * This copies only the columns present in BOTH the old and rebuilt tables, * so a column that is NOT NULL and absent from the old one makes the INSERT * fail: `NOT NULL constraint failed: analytics_rollup.eligible_operations`. * It was the only column added after the first schema without a default. * * The throw happened inside the constructor, so the migration left the store * unusable and every later save was lost. The symptom was pruneOldEntries * folding 0 rows -- four Node shards red on a test about day-grain folding, * for a reason that had nothing to do with folding. */ private restoreOldRollup; private initializeDatabase; /** * Save a single analytics entry (batched for performance) */ save(entry: AnalyticsEntry): Promise; /** * Save multiple analytics entries in a single transaction */ saveBatch(entries: AnalyticsEntry[]): Promise; /** * Folds whatever has aged out, occasionally. * * IT RUNS AFTER A WRITE, which is the only moment the store is known to be * growing. A prune on a schedule would wake a process that had nothing to do; * a prune on every write would read the oldest batch on every write. * * THE FIRST WRITE ALWAYS CHECKS, so a database that grew under an older build * -- or under a process that exits long before it reaches a counter -- is * actually bounded rather than bounded in principle. * * RE-ENTRANT ON PURPOSE-PROOF: the prune flushes before it reads, and that * flush can land here again. The flag makes the inner call a no-op instead of * a recursion. */ private maybePrune; /** * Schedule a delayed batch flush */ private scheduleBatchFlush; /** * Flush the current batch to database */ private flushBatch; /** * Query analytics entries with optional filters */ query(filters?: Partial): Promise; /** * Get all entries within a date range */ queryByDateRange(startDate: string, endDate: string): Promise; /** * Clear all analytics data */ clear(): Promise; /** * Get total count of stored entries */ count(): Promise; /** * Folds every row older than the report's window into per-day totals. * * ONE TRANSACTION, WHICH IS WHY THERE IS NO CRASH WINDOW HERE. The upsert * and the delete land together or neither lands, so unlike a file rewrite * there is no state where a day is both rows and a total. Nothing in the * readers has to resolve a half-done prune. * * IN BATCHES, because the alternative is reading a year of rows into memory * to save a disk. Each batch folds, merges and deletes the same ids it read, * so a batch that fails leaves the rows it had not reached untouched. */ pruneOldEntries(now?: Date): Promise; /** Days already folded into totals. */ getRollups(): Promise; /** * Convert database rows to AnalyticsEntry objects */ private rowsToEntries; /** * Close the database connection */ close(): Promise; } //# sourceMappingURL=analytics-storage.d.ts.map