Files
timmy-tower/lib/db/migrations/0005_free_tier.sql
alexpaynex 26e0d32f5c Task #27: Complete cost-routing + free-tier gate — all critical fixes applied
Fix 1 — Add `estimateRequestCost(request, model)` to PricingService (pricing.ts)
  - Unified: estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
  - Replaces duplicated estimation in jobs.ts, sessions.ts, estimate.ts

Fix 2 — Sessions pre-gate: estimate → decide → execute → reconcile
  - freeTierService.decide() runs on ESTIMATED cost BEFORE executeWork()
  - Fixed double-margin: estimateRequestCost already includes infra+margin; convert directly
  - absorbedSats capped at actual cost post-execution (Math.min)

Fix 3 — Correct isFree derivation for partial jobs in advanceJob() (jobs.ts)
  - isFreeExecution = workAmountSats === 0 (not job.freeTier)
  - Partial jobs run paid accounting: actual sats, refund, pool credit, deferred grant

Fix 4 — Defer ALL grant recording to post-work execution (jobs.ts)
  - Fully-free path: removed recordGrant from eval time; now in runWorkInBackground
  - For isFree jobs: absorbCap = actual post-execution cost (calculateActualChargeSats)
  - For partial jobs: grant deferred from invoice creation to after work completes

Fix 5 — Atomic, pool-bounded grant recording with row locking (free-tier.ts)
  - SELECT ... FOR UPDATE locks pool row inside transaction
  - actualAbsorbed = Math.min(absorbSats, poolBalance) — pool can never go negative
  - Daily absorption: SQL CASE expression atomically handles new-day reset
  - Audit log and identity counter both reflect actualAbsorbed, not requested amount
  - If pool is empty at grant time, transaction returns without writing

Fix 6 — Remove fire-and-forget from all recordGrant() call sites
  - All three call sites now use await; failures propagate correctly

Fix 7 — Add migration 0005_free_tier.sql
  - Creates timmy_config, free_tier_grants tables
  - Adds nostr_identities.sats_absorbed_today / absorbed_reset_at columns
  - Adds jobs.free_tier / absorbed_sats columns
  - Adds sessions.nostr_pubkey FK column (for migration-driven deploys)
  - All IF NOT EXISTS — safe to run on already-pushed DBs
2026-03-19 17:02:02 +00:00

50 lines
2.5 KiB
SQL

-- Migration: Free-tier cost routing (Task #27)
-- Adds generosity pool store, grant audit log, and free-tier tracking columns.
-- ── timmy_config ─────────────────────────────────────────────────────────────
-- Key/value store for Timmy's internal config (e.g. generosity_pool_sats).
CREATE TABLE IF NOT EXISTS timmy_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── nostr_identities: free-tier absorption columns ───────────────────────────
-- Rolling daily budget: how many sats Timmy has subsidised for this identity
-- today; resets when absorbed_reset_at is older than 24 h.
ALTER TABLE nostr_identities
ADD COLUMN IF NOT EXISTS sats_absorbed_today INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS absorbed_reset_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
-- ── jobs: free-tier tracking columns ─────────────────────────────────────────
-- free_tier=TRUE → Timmy absorbed part or all of this job's cost from the pool.
-- absorbed_sats → how many sats Timmy absorbed (NULL or 0 for fully-paid jobs).
ALTER TABLE jobs
ADD COLUMN IF NOT EXISTS free_tier BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS absorbed_sats INTEGER;
-- ── sessions: nostr identity FK ──────────────────────────────────────────────
-- Added in task #26; included here for completeness in migration-driven deploys.
ALTER TABLE sessions
ADD COLUMN IF NOT EXISTS nostr_pubkey TEXT
REFERENCES nostr_identities(pubkey);
-- ── free_tier_grants ─────────────────────────────────────────────────────────
-- Audit log: one row each time Timmy absorbs cost on behalf of an identity.
CREATE TABLE IF NOT EXISTS free_tier_grants (
id TEXT PRIMARY KEY,
pubkey TEXT NOT NULL REFERENCES nostr_identities(pubkey),
request_hash TEXT NOT NULL,
sats_absorbed INTEGER NOT NULL,
pool_balance_after INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_free_tier_grants_pubkey
ON free_tier_grants(pubkey);