import type Database from 'better-sqlite3'; import type { RedditPost } from '../reddit/api.js'; export type GrowthCommentStatus = | 'discovered' | 'posting' | 'posted' | 'verified' | 'rejected' | 'retryable'; /** * Hours during which an account never comments. Expressed in the host's local * time because the operator reasons about "overnight", not about UTC. */ export interface GrowthQuietHours { enabled: boolean; startHour: number; endHour: number; } export interface GrowthSettingsRecord { accountId: string; enabled: boolean; /** Rung on the adaptive allowance ladder. Higher means more comments per day. */ trustLevel: number; /** * Operator override for comments per day. Null leaves the allowance to the * adaptive ladder. A value is enforced verbatim — the ladder keeps tracking * the account underneath, and the removal/shadowban pause still applies. */ dailyLimit: number | null; levelChangedAt: number | null; consecutiveRemovals: number; /** Non-null means growth is hard-paused until an operator clears it. */ pausedReason: string | null; pausedAt: number | null; minDelayMs: number; jitterMs: number; quietHours: GrowthQuietHours; allowNsfw: boolean; updatedAt: number; } export interface GrowthSettingsPatch { enabled?: boolean; /** Pass null to hand the allowance back to the adaptive ladder. */ dailyLimit?: number | null; minDelayMs?: number; jitterMs?: number; quietHours?: GrowthQuietHours; allowNsfw?: boolean; } export interface GrowthSubredditRecord { accountId: string; subreddit: string; enabled: boolean; weight: number; dailyCap: number; disabledReason: string | null; cooldownUntil: number | null; addedAt: number; updatedAt: number; } export interface GrowthCommentRecord { accountId: string; postId: string; subreddit: string; status: GrowthCommentStatus; threadTitle: string; threadUrl: string; threadCreatedAt: number; threadNumComments: number; post: RedditPost; generatedComment: string | null; modelId: string | null; commentFullname: string | null; commentPermalink: string | null; firstScore: number | null; lastScore: number | null; removedAt: number | null; shadowCheckedAt: number | null; shadowBanned: boolean | null; verifyAttempts: number; nextVerifyAt: number | null; discoveredAt: number; postedAt: number | null; updatedAt: number; availableAt: number; leaseOwner: string | null; leaseExpiresAt: number | null; attemptCount: number; lastError: string | null; } export interface GrowthEnqueueResult { discovered: number; /** Already queued or posted by this account. */ duplicate: number; /** Claimed by a different account, so this one must leave it alone. */ ownedByOtherAccount: number; } export interface GrowthPostedOutcome { generatedComment: string; modelId: string; commentFullname: string; commentPermalink: string | null; nextVerifyAt: number; } export interface GrowthVerificationInput { score: number | null; removed: boolean; shadowBanned?: boolean | null; nextVerifyAt: number | null; } /** One posted comment reduced to the evidence the adaptive pacer needs. */ export interface GrowthOutcomeSample { postId: string; subreddit: string; postedAt: number; status: GrowthCommentStatus; lastScore: number | null; removed: boolean; shadowBanned: boolean | null; } export interface GrowthStats { byStatus: Record; postedLast24h: number; postedTotal: number; removedTotal: number; medianScore: number | null; } const ALL_STATUSES: GrowthCommentStatus[] = [ 'discovered', 'posting', 'posted', 'verified', 'rejected', 'retryable', ]; const DEFAULT_QUIET_HOURS: GrowthQuietHours = { enabled: true, startHour: 0, endHour: 7 }; /** * Per-account growth state: whether commenting is on, which communities it may * comment in, and the durable record of every comment it prepared or posted. * * Unlike the outreach candidate funnel this is deliberately account-scoped — * karma, comment history and trust level all belong to one identity — so the * only cross-account rule enforced here is that two accounts never comment on * the same thread. */ export class AccountGrowthRepository { constructor(private readonly db: Database.Database) {} // ---------- settings ---------- /** Returns defaults for an account that has never been configured. */ getSettings(accountId: string, now: number = Date.now()): GrowthSettingsRecord { const row = this.db.prepare('SELECT * FROM account_growth_settings WHERE account_id = ?') .get(accountId) as Record | undefined; if (!row) { return { accountId, enabled: false, trustLevel: 0, dailyLimit: null, levelChangedAt: null, consecutiveRemovals: 0, pausedReason: null, pausedAt: null, minDelayMs: 3_600_000, jitterMs: 900_000, quietHours: { ...DEFAULT_QUIET_HOURS }, allowNsfw: false, updatedAt: now, }; } return mapSettings(row); } saveSettings( accountId: string, patch: GrowthSettingsPatch, now: number = Date.now(), ): GrowthSettingsRecord { requireText(accountId, 'accountId'); if (patch.minDelayMs !== undefined && (!Number.isInteger(patch.minDelayMs) || patch.minDelayMs < 0)) { throw new Error('Growth minimum delay must be a non-negative integer'); } if (patch.jitterMs !== undefined && (!Number.isInteger(patch.jitterMs) || patch.jitterMs < 0)) { throw new Error('Growth jitter must be a non-negative integer'); } if (patch.dailyLimit !== undefined && patch.dailyLimit !== null && (!Number.isInteger(patch.dailyLimit) || patch.dailyLimit < 1 || patch.dailyLimit > 50)) { throw new Error('Daily comment limit must be a whole number between 1 and 50'); } if (patch.quietHours) validateQuietHours(patch.quietHours); const current = this.getSettings(accountId, now); const merged: GrowthSettingsRecord = { ...current, ...patch, quietHours: patch.quietHours ?? current.quietHours, // `null` is a meaningful value here — it hands the allowance back to the // adaptive ladder — so only an explicitly present key may change it. dailyLimit: 'dailyLimit' in patch ? patch.dailyLimit ?? null : current.dailyLimit, updatedAt: now, }; this.db.prepare(` INSERT INTO account_growth_settings ( account_id, enabled, trust_level, daily_limit, level_changed_at, consecutive_removals, paused_reason, paused_at, min_delay_ms, jitter_ms, quiet_hours_json, allow_nsfw, updated_at ) VALUES ( @accountId, @enabled, @trustLevel, @dailyLimit, @levelChangedAt, @consecutiveRemovals, @pausedReason, @pausedAt, @minDelayMs, @jitterMs, @quietHoursJson, @allowNsfw, @updatedAt ) ON CONFLICT(account_id) DO UPDATE SET enabled = excluded.enabled, daily_limit = excluded.daily_limit, min_delay_ms = excluded.min_delay_ms, jitter_ms = excluded.jitter_ms, quiet_hours_json = excluded.quiet_hours_json, allow_nsfw = excluded.allow_nsfw, updated_at = excluded.updated_at `).run({ accountId, enabled: merged.enabled ? 1 : 0, trustLevel: merged.trustLevel, dailyLimit: merged.dailyLimit, levelChangedAt: merged.levelChangedAt, consecutiveRemovals: merged.consecutiveRemovals, pausedReason: merged.pausedReason, pausedAt: merged.pausedAt, minDelayMs: merged.minDelayMs, jitterMs: merged.jitterMs, quietHoursJson: JSON.stringify(merged.quietHours), allowNsfw: merged.allowNsfw ? 1 : 0, updatedAt: now, }); return this.getSettings(accountId, now); } setTrustLevel(accountId: string, trustLevel: number, now: number = Date.now()): GrowthSettingsRecord { if (!Number.isInteger(trustLevel) || trustLevel < 0) { throw new Error('Growth trust level must be a non-negative integer'); } this.ensureSettingsRow(accountId, now); this.db.prepare(` UPDATE account_growth_settings SET trust_level = ?, level_changed_at = ?, updated_at = ? WHERE account_id = ? `).run(trustLevel, now, now, accountId); return this.getSettings(accountId, now); } /** * Hard-pauses growth for this account. Deliberately requires an operator to * clear it: every path that sets this has seen evidence that continuing would * damage the account. */ pause(accountId: string, reason: string, now: number = Date.now()): GrowthSettingsRecord { requireText(reason, 'reason'); this.ensureSettingsRow(accountId, now); this.db.prepare(` UPDATE account_growth_settings SET paused_reason = ?, paused_at = ?, updated_at = ? WHERE account_id = ? `).run(reason, now, now, accountId); return this.getSettings(accountId, now); } resume(accountId: string, now: number = Date.now()): GrowthSettingsRecord { this.ensureSettingsRow(accountId, now); this.db.prepare(` UPDATE account_growth_settings SET paused_reason = NULL, paused_at = NULL, consecutive_removals = 0, updated_at = ? WHERE account_id = ? `).run(now, accountId); return this.getSettings(accountId, now); } setConsecutiveRemovals(accountId: string, count: number, now: number = Date.now()): void { if (!Number.isInteger(count) || count < 0) { throw new Error('Consecutive removal count must be a non-negative integer'); } this.ensureSettingsRow(accountId, now); this.db.prepare(` UPDATE account_growth_settings SET consecutive_removals = ?, updated_at = ? WHERE account_id = ? `).run(count, now, accountId); } listEnabledAccountIds(): string[] { const rows = this.db.prepare(` SELECT account_id FROM account_growth_settings WHERE enabled = 1 AND paused_reason IS NULL ORDER BY account_id `).all() as Array<{ account_id: string }>; return rows.map((row) => row.account_id); } // ---------- subreddits ---------- listSubreddits(accountId: string): GrowthSubredditRecord[] { const rows = this.db.prepare(` SELECT * FROM account_growth_subreddits WHERE account_id = ? ORDER BY subreddit COLLATE NOCASE `).all(accountId) as Array>; return rows.map(mapSubreddit); } /** Subreddits eligible to be polled right now. */ listActiveSubreddits(accountId: string, now: number = Date.now()): GrowthSubredditRecord[] { return this.listSubreddits(accountId).filter((entry) => ( entry.enabled && (entry.cooldownUntil === null || entry.cooldownUntil <= now) )); } addSubreddit( accountId: string, subreddit: string, options: { weight?: number; dailyCap?: number } = {}, now: number = Date.now(), ): GrowthSubredditRecord { requireText(accountId, 'accountId'); const name = normalizeSubreddit(subreddit); const weight = options.weight ?? 1; const dailyCap = options.dailyCap ?? 2; if (!Number.isFinite(weight) || weight <= 0) throw new Error('Subreddit weight must be positive'); if (!Number.isInteger(dailyCap) || dailyCap < 1) throw new Error('Subreddit daily cap must be a positive integer'); this.db.prepare(` INSERT INTO account_growth_subreddits ( account_id, subreddit, enabled, weight, daily_cap, added_at, updated_at ) VALUES (?, ?, 1, ?, ?, ?, ?) ON CONFLICT(account_id, subreddit) DO UPDATE SET weight = excluded.weight, daily_cap = excluded.daily_cap, updated_at = excluded.updated_at `).run(accountId, name, weight, dailyCap, now, now); const record = this.listSubreddits(accountId) .find((entry) => entry.subreddit.toLowerCase() === name.toLowerCase()); if (!record) throw new Error(`Growth subreddit ${name} was not persisted`); return record; } setSubredditEnabled( accountId: string, subreddit: string, enabled: boolean, now: number = Date.now(), ): boolean { return this.db.prepare(` UPDATE account_growth_subreddits SET enabled = ?, disabled_reason = CASE WHEN ? = 1 THEN NULL ELSE disabled_reason END, updated_at = ? WHERE account_id = ? AND subreddit = ? COLLATE NOCASE `).run(enabled ? 1 : 0, enabled ? 1 : 0, now, accountId, normalizeSubreddit(subreddit)).changes === 1; } /** Reddit refused this account in this community; stop polling it entirely. */ disableSubreddit( accountId: string, subreddit: string, reason: string, now: number = Date.now(), ): boolean { requireText(reason, 'reason'); return this.db.prepare(` UPDATE account_growth_subreddits SET enabled = 0, disabled_reason = ?, updated_at = ? WHERE account_id = ? AND subreddit = ? COLLATE NOCASE `).run(reason, now, accountId, normalizeSubreddit(subreddit)).changes === 1; } cooldownSubreddit( accountId: string, subreddit: string, until: number, now: number = Date.now(), ): boolean { return this.db.prepare(` UPDATE account_growth_subreddits SET cooldown_until = MAX(COALESCE(cooldown_until, 0), ?), updated_at = ? WHERE account_id = ? AND subreddit = ? COLLATE NOCASE `).run(until, now, accountId, normalizeSubreddit(subreddit)).changes === 1; } removeSubreddit(accountId: string, subreddit: string): boolean { return this.db.prepare(` DELETE FROM account_growth_subreddits WHERE account_id = ? AND subreddit = ? COLLATE NOCASE `).run(accountId, normalizeSubreddit(subreddit)).changes === 1; } // ---------- comment queue ---------- enqueueDiscovered( accountId: string, posts: readonly RedditPost[], now: number = Date.now(), maxNew: number = Number.POSITIVE_INFINITY, ): GrowthEnqueueResult { requireText(accountId, 'accountId'); const result: GrowthEnqueueResult = { discovered: 0, duplicate: 0, ownedByOtherAccount: 0 }; const existing = this.db.prepare( 'SELECT account_id FROM account_growth_comments WHERE post_id = ?', ); const insert = this.db.prepare(` INSERT INTO account_growth_comments ( account_id, post_id, subreddit, status, thread_title, thread_url, thread_created_at, thread_num_comments, payload_json, discovered_at, updated_at, available_at ) VALUES (?, ?, ?, 'discovered', ?, ?, ?, ?, ?, ?, ?, ?) `); this.db.transaction(() => { for (const post of posts) { if (result.discovered >= maxNew) break; const owner = existing.get(post.id) as { account_id: string } | undefined; if (owner) { if (owner.account_id === accountId) result.duplicate += 1; else result.ownedByOtherAccount += 1; continue; } insert.run( accountId, post.id, post.subreddit, post.title, `https://www.reddit.com${post.permalink}`, Math.round(post.created_utc * 1_000), numComments(post), JSON.stringify(post), now, now, now, ); result.discovered += 1; } })(); return result; } /** * Leases the next thread this account should comment on. Generation and * posting share one lease because an auto-posting run has no operator pause * between them. */ claimNext( accountId: string, workerId: string, leaseMs: number, now: number = Date.now(), ): GrowthCommentRecord | null { requireText(workerId, 'workerId'); if (!Number.isFinite(leaseMs) || leaseMs <= 0) throw new Error('Growth lease must be positive'); return this.db.transaction(() => { const row = this.db.prepare(` SELECT post_id FROM account_growth_comments WHERE account_id = ? AND status IN ('discovered', 'retryable') AND available_at <= ? ORDER BY CASE status WHEN 'retryable' THEN 0 ELSE 1 END, CASE status WHEN 'retryable' THEN available_at END ASC, thread_created_at DESC, discovered_at ASC LIMIT 1 `).get(accountId, now) as { post_id: string } | undefined; if (!row) return null; const changed = this.db.prepare(` UPDATE account_growth_comments SET status = 'posting', lease_owner = ?, lease_expires_at = ?, updated_at = ?, attempt_count = attempt_count + 1 WHERE account_id = ? AND post_id = ? AND status IN ('discovered', 'retryable') `).run(workerId, now + leaseMs, now, accountId, row.post_id).changes; return changed === 1 ? this.get(accountId, row.post_id) : null; })(); } /** * Includes retries whose cooldown is still active. Discovery uses this to * avoid growing the backlog while existing work is waiting to recover. */ hasPendingQueueWork(accountId: string): boolean { return this.db.prepare(` SELECT 1 FROM account_growth_comments WHERE account_id = ? AND status IN ('discovered', 'retryable') LIMIT 1 `).get(accountId) !== undefined; } /** * Clears only work that has not reached Reddit. A posting lease is excluded * because its remote outcome may be uncertain, and posted audit history is * never touched. */ clearPendingQueue(accountId: string): number { return this.db.prepare(` DELETE FROM account_growth_comments WHERE account_id = ? AND status IN ('discovered', 'retryable') `).run(accountId).changes; } markPosted( accountId: string, postId: string, workerId: string, outcome: GrowthPostedOutcome, now: number = Date.now(), ): boolean { return this.db.prepare(` UPDATE account_growth_comments SET status = 'posted', generated_comment = ?, model_id = ?, comment_fullname = ?, comment_permalink = ?, posted_at = ?, next_verify_at = ?, updated_at = ?, lease_owner = NULL, lease_expires_at = NULL, last_error = NULL WHERE account_id = ? AND post_id = ? AND status = 'posting' AND lease_owner = ? `).run( outcome.generatedComment, outcome.modelId, outcome.commentFullname, outcome.commentPermalink, now, outcome.nextVerifyAt, now, accountId, postId, workerId, ).changes === 1; } /** * Terminal rejection. The generated text is kept when there was one so a * safety-gate refusal stays auditable rather than vanishing. */ reject( accountId: string, postId: string, workerId: string, reason: string, generatedComment: string | null = null, now: number = Date.now(), ): boolean { requireText(reason, 'reason'); return this.db.prepare(` UPDATE account_growth_comments SET status = 'rejected', last_error = ?, generated_comment = COALESCE(?, generated_comment), updated_at = ?, lease_owner = NULL, lease_expires_at = NULL WHERE account_id = ? AND post_id = ? AND status = 'posting' AND lease_owner = ? `).run(reason, generatedComment, now, accountId, postId, workerId).changes === 1; } defer( accountId: string, postId: string, workerId: string, reason: string, availableAt: number, now: number = Date.now(), ): boolean { requireText(reason, 'reason'); return this.db.prepare(` UPDATE account_growth_comments SET status = 'retryable', last_error = ?, available_at = ?, updated_at = ?, lease_owner = NULL, lease_expires_at = NULL WHERE account_id = ? AND post_id = ? AND status = 'posting' AND lease_owner = ? `).run(reason, availableAt, now, accountId, postId, workerId).changes === 1; } /** * An interrupted worker may or may not have reached Reddit before dying, so a * recovered row is never retried automatically — it is rejected and left in * the audit trail. Retrying risks a duplicate comment, which is both a * spam signal and impossible to undo. */ recoverLeases(accountId: string, now: number = Date.now()): number { return this.db.prepare(` UPDATE account_growth_comments SET status = 'rejected', lease_owner = NULL, lease_expires_at = NULL, updated_at = ?, last_error = 'Interrupted while posting; not retried to avoid a duplicate comment' WHERE account_id = ? AND status = 'posting' `).run(now, accountId).changes; } countPostedSince(accountId: string, since: number): number { return (this.db.prepare(` SELECT COUNT(*) AS count FROM account_growth_comments WHERE account_id = ? AND posted_at IS NOT NULL AND posted_at >= ? `).get(accountId, since) as { count: number }).count; } countPostedInSubredditSince(accountId: string, subreddit: string, since: number): number { return (this.db.prepare(` SELECT COUNT(*) AS count FROM account_growth_comments WHERE account_id = ? AND subreddit = ? COLLATE NOCASE AND posted_at IS NOT NULL AND posted_at >= ? `).get(accountId, normalizeSubreddit(subreddit), since) as { count: number }).count; } getLastPostedAt(accountId: string): number | null { return (this.db.prepare(` SELECT MAX(posted_at) AS at FROM account_growth_comments WHERE account_id = ? `).get(accountId) as { at: number | null }).at; } /** Posted comments whose next verification sweep is due. */ listDueForVerification( accountId: string, limit = 100, now: number = Date.now(), ): GrowthCommentRecord[] { if (!Number.isInteger(limit) || limit < 1 || limit > 100) { throw new Error('Verification limit must be an integer between 1 and 100'); } const rows = this.db.prepare(` SELECT * FROM account_growth_comments WHERE account_id = ? AND status = 'posted' AND comment_fullname IS NOT NULL AND next_verify_at IS NOT NULL AND next_verify_at <= ? ORDER BY next_verify_at ASC LIMIT ? `).all(accountId, now, limit) as Array>; return rows.map(mapComment); } recordVerification( accountId: string, postId: string, input: GrowthVerificationInput, now: number = Date.now(), ): boolean { const status = input.removed || input.nextVerifyAt === null ? 'verified' : 'posted'; return this.db.prepare(` UPDATE account_growth_comments SET status = ?, first_score = COALESCE(first_score, ?), last_score = COALESCE(?, last_score), removed_at = CASE WHEN ? = 1 THEN COALESCE(removed_at, ?) ELSE removed_at END, shadow_banned = COALESCE(?, shadow_banned), shadow_checked_at = CASE WHEN ? IS NULL THEN shadow_checked_at ELSE ? END, verify_attempts = verify_attempts + 1, next_verify_at = ?, updated_at = ? WHERE account_id = ? AND post_id = ? AND status IN ('posted', 'verified') `).run( status, input.score, input.score, input.removed ? 1 : 0, now, input.shadowBanned === undefined || input.shadowBanned === null ? null : (input.shadowBanned ? 1 : 0), input.shadowBanned === undefined || input.shadowBanned === null ? null : now, now, input.nextVerifyAt, now, accountId, postId, ).changes === 1; } /** Evidence for the adaptive pacer: what happened to recent posted comments. */ listOutcomesSince(accountId: string, since: number): GrowthOutcomeSample[] { const rows = this.db.prepare(` SELECT post_id, subreddit, posted_at, status, last_score, removed_at, shadow_banned FROM account_growth_comments WHERE account_id = ? AND posted_at IS NOT NULL AND posted_at >= ? ORDER BY posted_at ASC `).all(accountId, since) as Array>; return rows.map((row) => ({ postId: row.post_id as string, subreddit: row.subreddit as string, postedAt: row.posted_at as number, status: row.status as GrowthCommentStatus, lastScore: (row.last_score as number | null) ?? null, removed: row.removed_at !== null, shadowBanned: row.shadow_banned === null ? null : row.shadow_banned === 1, })); } listRecent(accountId: string, limit = 50): GrowthCommentRecord[] { if (!Number.isInteger(limit) || limit < 1 || limit > 200) { throw new Error('Growth history limit must be an integer between 1 and 200'); } const rows = this.db.prepare(` SELECT * FROM account_growth_comments WHERE account_id = ? ORDER BY COALESCE(posted_at, discovered_at) DESC LIMIT ? `).all(accountId, limit) as Array>; return rows.map(mapComment); } get(accountId: string, postId: string): GrowthCommentRecord | null { const row = this.db.prepare( 'SELECT * FROM account_growth_comments WHERE account_id = ? AND post_id = ?', ).get(accountId, postId) as Record | undefined; return row ? mapComment(row) : null; } getStats(accountId: string, now: number = Date.now()): GrowthStats { const byStatus = Object.fromEntries( ALL_STATUSES.map((status) => [status, 0]), ) as Record; const rows = this.db.prepare(` SELECT status, COUNT(*) AS count FROM account_growth_comments WHERE account_id = ? GROUP BY status `).all(accountId) as Array<{ status: GrowthCommentStatus; count: number }>; for (const row of rows) byStatus[row.status] = row.count; const scores = (this.db.prepare(` SELECT last_score FROM account_growth_comments WHERE account_id = ? AND last_score IS NOT NULL ORDER BY last_score ASC `).all(accountId) as Array<{ last_score: number }>).map((row) => row.last_score); return { byStatus, postedLast24h: this.countPostedSince(accountId, now - 86_400_000), postedTotal: (this.db.prepare(` SELECT COUNT(*) AS count FROM account_growth_comments WHERE account_id = ? AND posted_at IS NOT NULL `).get(accountId) as { count: number }).count, removedTotal: (this.db.prepare(` SELECT COUNT(*) AS count FROM account_growth_comments WHERE account_id = ? AND removed_at IS NOT NULL `).get(accountId) as { count: number }).count, medianScore: median(scores), }; } /** Drops stale unposted candidates so the queue never serves an ancient thread. */ expireStale(accountId: string, cutoff: number, now: number = Date.now()): number { return this.db.prepare(` UPDATE account_growth_comments SET status = 'rejected', last_error = 'Thread aged out before a comment was posted', updated_at = ?, lease_owner = NULL, lease_expires_at = NULL WHERE account_id = ? AND status IN ('discovered', 'retryable') AND thread_created_at < ? `).run(now, accountId, cutoff).changes; } private ensureSettingsRow(accountId: string, now: number): void { requireText(accountId, 'accountId'); this.db.prepare(` INSERT INTO account_growth_settings (account_id, updated_at) VALUES (?, ?) ON CONFLICT(account_id) DO NOTHING `).run(accountId, now); } } function numComments(post: RedditPost): number { const value = (post as unknown as { num_comments?: unknown }).num_comments; return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0; } function median(sorted: readonly number[]): number | null { if (sorted.length === 0) return null; const middle = Math.floor(sorted.length / 2); return sorted.length % 2 === 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; } function mapSettings(row: Record): GrowthSettingsRecord { return { accountId: row.account_id as string, enabled: row.enabled === 1, trustLevel: row.trust_level as number, dailyLimit: (row.daily_limit as number | null) ?? null, levelChangedAt: (row.level_changed_at as number | null) ?? null, consecutiveRemovals: row.consecutive_removals as number, pausedReason: (row.paused_reason as string | null) ?? null, pausedAt: (row.paused_at as number | null) ?? null, minDelayMs: row.min_delay_ms as number, jitterMs: row.jitter_ms as number, quietHours: parseQuietHours(row.quiet_hours_json as string), allowNsfw: row.allow_nsfw === 1, updatedAt: row.updated_at as number, }; } function mapSubreddit(row: Record): GrowthSubredditRecord { return { accountId: row.account_id as string, subreddit: row.subreddit as string, enabled: row.enabled === 1, weight: row.weight as number, dailyCap: row.daily_cap as number, disabledReason: (row.disabled_reason as string | null) ?? null, cooldownUntil: (row.cooldown_until as number | null) ?? null, addedAt: row.added_at as number, updatedAt: row.updated_at as number, }; } function mapComment(row: Record): GrowthCommentRecord { return { accountId: row.account_id as string, postId: row.post_id as string, subreddit: row.subreddit as string, status: row.status as GrowthCommentStatus, threadTitle: row.thread_title as string, threadUrl: row.thread_url as string, threadCreatedAt: row.thread_created_at as number, threadNumComments: row.thread_num_comments as number, post: JSON.parse(row.payload_json as string) as RedditPost, generatedComment: (row.generated_comment as string | null) ?? null, modelId: (row.model_id as string | null) ?? null, commentFullname: (row.comment_fullname as string | null) ?? null, commentPermalink: (row.comment_permalink as string | null) ?? null, firstScore: (row.first_score as number | null) ?? null, lastScore: (row.last_score as number | null) ?? null, removedAt: (row.removed_at as number | null) ?? null, shadowCheckedAt: (row.shadow_checked_at as number | null) ?? null, shadowBanned: row.shadow_banned === null || row.shadow_banned === undefined ? null : row.shadow_banned === 1, verifyAttempts: row.verify_attempts as number, nextVerifyAt: (row.next_verify_at as number | null) ?? null, discoveredAt: row.discovered_at as number, postedAt: (row.posted_at as number | null) ?? null, updatedAt: row.updated_at as number, availableAt: row.available_at as number, leaseOwner: (row.lease_owner as string | null) ?? null, leaseExpiresAt: (row.lease_expires_at as number | null) ?? null, attemptCount: row.attempt_count as number, lastError: (row.last_error as string | null) ?? null, }; } function parseQuietHours(json: string): GrowthQuietHours { try { const parsed = JSON.parse(json) as Partial; const quietHours = { enabled: parsed.enabled !== false, startHour: typeof parsed.startHour === 'number' ? parsed.startHour : DEFAULT_QUIET_HOURS.startHour, endHour: typeof parsed.endHour === 'number' ? parsed.endHour : DEFAULT_QUIET_HOURS.endHour, }; validateQuietHours(quietHours); return quietHours; } catch { return { ...DEFAULT_QUIET_HOURS }; } } function validateQuietHours(quietHours: GrowthQuietHours): void { for (const hour of [quietHours.startHour, quietHours.endHour]) { if (!Number.isInteger(hour) || hour < 0 || hour > 23) { throw new Error('Quiet hours must be whole hours between 0 and 23'); } } } export function normalizeSubreddit(value: string): string { const trimmed = value.trim().replace(/^\/?r\//i, ''); if (!/^[A-Za-z0-9][A-Za-z0-9_]{1,20}$/.test(trimmed)) { throw new Error(`"${value}" is not a valid subreddit name`); } return trimmed; } function requireText(value: string, name: string): void { if (!value.trim()) throw new Error(`${name} must not be empty`); }