GenerAI / worldmonitor /convex /schema.ts
amogaddy's picture
Fix build: ripristinate blog-site/tests/e2e/pro-test/convex (referenziate dagli script di build) (part 2)
97ee7cb verified
Raw
History Blame Contribute Delete
56.5 kB
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
import {
channelTypeValidator,
digestModeValidator,
proActivationStepIdValidator,
quietHoursOverrideValidator,
sensitivityValidator,
} from "./constants";
// Subscription status enum β€” maps Dodo statuses to our internal set
const subscriptionStatus = v.union(
v.literal("active"),
v.literal("on_hold"),
v.literal("cancelled"),
v.literal("expired"),
);
// Payment event status enum β€” covers charge outcomes and dispute lifecycle.
// `processing` / `requires_customer_action` are NON-terminal states (3DS/SCA
// in flight); persisting them gives the app a pending-payment signal for
// duplicate-prevention (#4438) and reconciliation (#4439). `cancelled` is a
// terminal-but-uncharged outcome. See convex/payments/webhookMutations.ts.
const paymentEventStatus = v.union(
v.literal("succeeded"),
v.literal("failed"),
v.literal("processing"),
v.literal("requires_customer_action"),
v.literal("cancelled"),
v.literal("dispute_opened"),
v.literal("dispute_won"),
v.literal("dispute_lost"),
v.literal("dispute_closed"),
);
const apiPlanLimitDimension = v.union(
v.literal("api_daily_requests"),
v.literal("api_minute_burst"),
v.literal("mcp_daily_calls"),
v.literal("mcp_minute_burst"),
);
const apiPlanLimitNoticeState = v.union(
v.literal("warning"),
v.literal("over_limit"),
v.literal("sustained_burst"),
);
const apiPlanLimitEmailStatus = v.union(
v.literal("pending"),
v.literal("sent"),
v.literal("skipped"),
v.literal("suppressed"),
v.literal("failed"),
);
const apiPlanLimitCtaKind = v.union(
v.literal("checkout"),
v.literal("billing_portal"),
v.literal("contact_support"),
v.literal("none"),
);
export default defineSchema({
userPreferences: defineTable({
userId: v.string(),
variant: v.string(),
data: v.any(),
schemaVersion: v.number(),
updatedAt: v.number(),
syncVersion: v.number(),
}).index("by_user_variant", ["userId", "variant"]),
userPreferenceWriteRateLimits: defineTable({
userId: v.string(),
windowStart: v.number(),
count: v.number(),
updatedAt: v.number(),
}).index("by_user_window", ["userId", "windowStart"]),
notificationChannels: defineTable(
v.union(
v.object({
userId: v.string(),
channelType: v.literal("telegram"),
chatId: v.string(),
verified: v.boolean(),
linkedAt: v.number(),
}),
v.object({
userId: v.string(),
channelType: v.literal("slack"),
webhookEnvelope: v.string(),
verified: v.boolean(),
linkedAt: v.number(),
slackChannelName: v.optional(v.string()),
slackTeamName: v.optional(v.string()),
slackConfigurationUrl: v.optional(v.string()),
}),
v.object({
userId: v.string(),
channelType: v.literal("email"),
email: v.string(),
verified: v.boolean(),
linkedAt: v.number(),
}),
v.object({
userId: v.string(),
channelType: v.literal("discord"),
webhookEnvelope: v.string(),
verified: v.boolean(),
linkedAt: v.number(),
discordGuildId: v.optional(v.string()),
discordChannelId: v.optional(v.string()),
}),
v.object({
userId: v.string(),
channelType: v.literal("webhook"),
webhookEnvelope: v.string(),
verified: v.boolean(),
linkedAt: v.number(),
webhookLabel: v.optional(v.string()),
webhookSecret: v.optional(v.string()),
}),
// Web Push (Phase 6). endpoint+p256dh+auth are the standard
// PushSubscription identity triple β€” not secrets, just per-device
// pairing material (they identify the browser's push endpoint at
// Mozilla/Google/Apple). Stored plaintext to match the rest of
// this table. userAgent is cosmetic: lets the settings UI show
// "Chrome Β· MacOS" next to the Remove button so users can tell
// which device a subscription belongs to.
v.object({
userId: v.string(),
channelType: v.literal("web_push"),
endpoint: v.string(),
p256dh: v.string(),
auth: v.string(),
verified: v.boolean(),
linkedAt: v.number(),
userAgent: v.optional(v.string()),
}),
),
)
.index("by_user", ["userId"])
.index("by_user_channel", ["userId", "channelType"]),
alertRules: defineTable({
userId: v.string(),
variant: v.string(),
enabled: v.boolean(),
eventTypes: v.array(v.string()),
sensitivity: sensitivityValidator,
channels: v.array(channelTypeValidator),
updatedAt: v.number(),
quietHoursEnabled: v.optional(v.boolean()),
quietHoursStart: v.optional(v.number()),
quietHoursEnd: v.optional(v.number()),
quietHoursTimezone: v.optional(v.string()),
quietHoursOverride: v.optional(quietHoursOverrideValidator),
// Digest mode fields (absent = realtime, same as digestMode: "realtime")
digestMode: v.optional(digestModeValidator),
digestHour: v.optional(v.number()), // 0-23 local hour for daily/twice_daily
digestTimezone: v.optional(v.string()), // IANA timezone, e.g. "America/New_York"
aiDigestEnabled: v.optional(v.boolean()), // opt-in AI executive summary in digests (default true for new rules)
// Optional country-scope (ISO-3166 alpha-2). Empty/absent β†’ all countries (current behavior).
countries: v.optional(v.array(v.string())),
// Optional watchlist ticker-scope (#4922 U3, e.g. ["AAPL", "RELIANCE.NS"]).
// Unlike `countries`, this is OPT-IN scoped: empty/absent β†’ the rule
// receives NO `watchlist_story_alert` events (the relay requires a
// non-empty intersection with the story's tickers).
tickers: v.optional(v.array(v.string())),
})
.index("by_user", ["userId"])
.index("by_user_variant", ["userId", "variant"])
.index("by_enabled", ["enabled"]),
// ────────────────────────────────────────────────────────────────────────
// Followed countries (watchlist primitive). See
// docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md
// (U12). One row per (userId, country) follow; uniqueness is enforced by
// the `followCountry` mutation via the `by_user_country` index check, NOT
// by Convex schema (Convex does not support unique constraints).
//
// `country` is a canonical ISO 3166-1 alpha-2 code (uppercase, e.g. "US",
// "GB", "JP"). Validation against the canonical alpha-2 registry happens
// at the mutation boundary (U13: `convex/lib/iso2.ts::isValidIso2`).
followedCountries: defineTable({
userId: v.string(),
country: v.string(),
addedAt: v.number(),
})
.index("by_user", ["userId"])
.index("by_country", ["country"])
.index("by_user_country", ["userId", "country"]),
// Aggregate-counter table for `countFollowers`. One row per country, kept
// in lockstep with `followedCountries` row inserts/deletes by the
// followCountry/unfollowCountry/mergeAnonymousLocal mutations (atomic
// patch within the same Convex mutation transaction). Lets the public
// `countFollowers` query be O(1) instead of O(n) per call. The privacy
// floor (`COUNTRY_COUNT_PRIVACY_FLOOR`) is applied at read time in the
// query, not at write time β€” the row stores the true count.
followedCountriesCounts: defineTable({
country: v.string(),
count: v.number(),
updatedAt: v.number(),
}).index("by_country", ["country"]),
// Pre-seeded per-country lock table for aggregate counter writes.
// The user shard lock only serializes mutations by user; first-ever
// follows of the same country by different users need an existing
// country-scoped document for Convex OCC to serialize the lazy
// `followedCountriesCounts` row creation/update path. One row is seeded
// for each valid ISO-2 code, and every counter +/- operation reads and
// patches the row for that country in the same transaction.
followedCountriesCountryLocks: defineTable({
country: v.string(),
lastTouchedAt: v.number(),
}).index("by_country", ["country"]),
// Per-user serialization document for the followed-countries watchlist.
// EVERY mutation that mutates `followedCountries` for a user reads AND
// writes this row, forcing Convex's per-document OCC to serialize
// concurrent same-user mutations. Without this, two parallel
// `followCountry` calls from the same user can both pass the cap check
// (Convex OCC tracks reads at the document level, not at the index-range
// level), both insert, and bypass the cap. The denormalized `count`
// also lets the cap check be O(1) instead of O(n) β€” happy side effect.
//
// Invariant: `count` MUST equal the row count of `followedCountries`
// for `userId`. The mutations are the only writers; tests assert this
// parity after every operation. See plan U13 / Codex round-3 P0
// (run 20260502-195816-dae403d7).
//
// KEY CAVEAT (Codex round-4 P0 v2): this row is created LAZILY on the
// first mutation, so its OCC alone does NOT close a brand-new user's
// race β€” two parallel first-ever mutations would both read empty and
// both insert, producing duplicate meta rows. The fix is the pre-seeded
// `followedCountriesShards` table below: every mutation reads + patches
// the shard row at `userIdToShard(userId)` BEFORE this lazy-create can
// happen, and Convex's OCC on the shard row serializes the two parallel
// mutations so the second one observes the first's user-meta insert.
followedCountriesUserMeta: defineTable({
userId: v.string(),
count: v.number(),
updatedAt: v.number(),
}).index("by_user", ["userId"]),
// Pre-seeded sharded lock table for the followed-countries watchlist
// (Codex round-4 P0 v2). One row per shard id `0..SHARD_COUNT-1`.
// Mapped to via `convex/lib/shards.ts::userIdToShard(userId)`, a
// deterministic non-cryptographic hash. Every mutation that touches
// `followedCountries` for a user reads the shard row at the top of the
// handler AND patches `lastTouchedAt` at the end β€” that read+write pair
// is what triggers Convex's per-document OCC to serialize concurrent
// same-user mutations. Because rows are pre-seeded (never lazily
// created), there is no TOCTOU window: the loser of an OCC race retries
// against the post-winner state, sees the user-meta row the winner
// inserted, and proceeds correctly.
//
// SHARD_COUNT is fixed at deploy time. Re-seeding requires draining
// in-flight mutations; do not change without an operator runbook.
// Seeding is idempotent β€” `_seedShards` skips existing rows. A daily
// cron + manual operator mutation guarantee the table stays seeded.
followedCountriesShards: defineTable({
shardId: v.number(),
lastTouchedAt: v.number(),
}).index("by_shard", ["shardId"]),
telegramPairingTokens: defineTable({
userId: v.string(),
token: v.string(),
expiresAt: v.number(),
used: v.boolean(),
variant: v.optional(v.string()),
})
.index("by_token", ["token"])
.index("by_user", ["userId"]),
registrations: defineTable({
email: v.string(),
normalizedEmail: v.string(),
registeredAt: v.number(),
source: v.optional(v.string()),
appVersion: v.optional(v.string()),
referralCode: v.optional(v.string()),
referredBy: v.optional(v.string()),
referralCount: v.optional(v.number()),
// Per-row stamp recording which PRO-launch broadcast wave a
// registrant landed in (e.g. "canary-250", "wave-2", "wave-3").
// Future wave-export actions filter on `proLaunchWave === undefined`
// to pick only un-emailed registrants. Optional so existing rows
// pass schema validation; the canary-250 backfill stamps the 244
// contacts already emailed yesterday, future waves stamp themselves
// at export time.
proLaunchWave: v.optional(v.string()),
proLaunchWaveAssignedAt: v.optional(v.number()),
})
.index("by_normalized_email", ["normalizedEmail"])
.index("by_referral_code", ["referralCode"])
// Index on the wave stamp so future picks can scan only-stamped
// / only-unstamped efficiently without a full table scan against
// tens of thousands of registrations.
.index("by_proLaunchWave", ["proLaunchWave"]),
// Singleton config for the cron-driven broadcast ramp runner. One
// row, keyed by the literal string "current" so admin mutations
// can target it without juggling Convex ids.
//
// The daily cron reads this row, checks the previous wave's
// kill-gate metrics, and (if green) advances to the next tier in
// `rampCurve`. Operator interventions (pause / resume / clear
// kill-gate / abort) are admin mutations on this row.
//
// We DELIBERATELY don't auto-clear `killGateTripped` β€” once the
// ramp halts itself, an operator must explicitly clear before the
// next cron run resumes. Better one extra dashboard click than a
// silent resumption after a real deliverability incident.
broadcastRampConfig: defineTable({
key: v.string(), // always "current"
active: v.boolean(),
// Wave sizes in order. e.g. [500, 1500, 5000, 15000, 25000].
// Each cron tick advances `currentTier` by 1 and uses
// `rampCurve[currentTier]` as the next wave's count.
rampCurve: v.array(v.number()),
// Index into rampCurve. -1 = not started; ramp ends when
// currentTier === rampCurve.length - 1.
currentTier: v.number(),
// Naming prefix for waves; e.g. "wave" β†’ "wave-2", "wave-3".
// The number suffix is `currentTier + waveLabelOffset` so the
// first auto-ramp wave can pick up where manual canary/wave-2
// left off (default offset 3 means tier 0 β†’ "wave-3").
waveLabelPrefix: v.string(),
waveLabelOffset: v.number(),
// Kill thresholds. Defaults match metrics.ts: 4% bounce, 0.08%
// complaint. Stored on the config so an operator can tighten
// them without redeploying.
bounceKillThreshold: v.number(),
complaintKillThreshold: v.number(),
// Kill-gate latch. Set to true by the cron when the prior
// wave's stats trip a threshold. Cleared only by explicit
// operator action.
killGateTripped: v.boolean(),
killGateReason: v.optional(v.string()),
// Tracking the last successfully-sent wave so the next cron
// tick can fetch its stats for the kill-gate check.
lastWaveLabel: v.optional(v.string()),
lastWaveBroadcastId: v.optional(v.string()),
lastWaveSegmentId: v.optional(v.string()),
lastWaveSentAt: v.optional(v.number()),
lastWaveAssigned: v.optional(v.number()),
// Status of the last cron run β€” distinct from the last wave.
// `succeeded` β€” wave sent cleanly
// `kill-gate-tripped`β€” prior-wave check halted the ramp
// `pool-drained` β€” assignAndExportWave returned underfilled
// with assigned < threshold
// `partial-failure` β€” wave action threw mid-flight; needs ops
// intervention before next run
// `awaiting-prior-stats` β€” prior wave hasn't accumulated enough
// delivered events yet; cron will retry
lastRunStatus: v.optional(v.string()),
lastRunAt: v.optional(v.number()),
lastRunError: v.optional(v.string()),
// Lease for the in-flight cron run. Set atomically by `_claimTierForRun`
// BEFORE the runner makes any external side effects (assignAndExportWave,
// createProLaunchBroadcast, sendProLaunchBroadcast). Cleared by
// `_recordWaveSent` (success), `_recordRunOutcome` (failure for the
// owning runId), `recoverFromPartialFailure` (operator), or
// `forceReleaseLease` (operator, last-resort). Two overlapping cron runs
// both attempting `_claimTierForRun` will see a lease already held and
// exit before any duplicate emails go out. There is NO automatic
// staleness override β€” long-running side effects (large waves) must not
// be racable just because they exceed an arbitrary clock; recovery from
// a genuinely-stuck lease is operator-only via `forceReleaseLease`.
pendingRunId: v.optional(v.string()),
pendingRunStartedAt: v.optional(v.number()),
// Per-step progress markers persisted by the in-flight run AFTER each
// external action succeeds. Lets `recoverFromPartialFailure` recover
// without operator-supplied metadata when the action dies between steps
// (e.g. Convex action timeout, OOM) before the catch can record
// partial-failure. Cleared on successful `_recordWaveSent` and on
// `recoverFromPartialFailure` completion.
pendingWaveLabel: v.optional(v.string()),
pendingSegmentId: v.optional(v.string()),
pendingAssigned: v.optional(v.number()),
pendingExportAt: v.optional(v.number()),
pendingBroadcastId: v.optional(v.string()),
pendingBroadcastAt: v.optional(v.number()),
// Locale filter switch β€” when true, pickWaveAction excludes
// contacts whose `users.localePrimary` (or email-TLD heuristic
// fallback) is non-English. Optional + missing-reads-as-false on
// the config β€” existing ramp rows that pre-date this feature
// continue with byte-identical behavior. Operator opts in via
// `initRamp({excludeNonEnglish: true})`.
excludeNonEnglish: v.optional(v.boolean()),
}).index("by_key", ["key"]),
// ────────────────────────────────────────────────────────────────────────
// Plan 2026-04-29 (post-launch-stabilization PR 2): wave-loading state
// machine. Replaces the monolithic `assignAndExportWave` action β€” which
// hits the Convex 10-min runtime budget at ~1500 contacts β€” with a
// multi-step pipeline (pick β†’ push-batchΓ—N β†’ finalize) that fits within
// budget at any wave size.
//
// `waveRuns` is the per-run state row. `wavePickedContacts` is the
// per-contact tri-state row that the push pipeline drains in batches.
// Together they are the durable source of truth for an in-flight wave;
// `broadcastRampConfig.lastWave*` is updated atomically by
// `_finalizeWaveRun` only when the whole pipeline succeeds.
//
// See `convex/broadcast/waveRuns.ts` for the function-shape rules
// (internalAction = external I/O, internalMutation = DB writes only)
// and the lease/recovery semantics.
// ────────────────────────────────────────────────────────────────────────
waveRuns: defineTable({
// Unique per pickWave call. Same string is set as
// `broadcastRampConfig.pendingRunId` for lease coordination β€” the
// existing rampRunner lease pattern. Cleared on `_finalizeWaveRun`
// success or operator recovery (`discardWaveRun`).
runId: v.string(),
waveLabel: v.string(),
segmentId: v.optional(v.string()),
// Lifecycle:
// picking β†’ reservoir-sampling + creating segment + persisting picked rows
// segment-created β†’ ready for first pushBatchAction
// pushing β†’ at least one batch in flight; remaining `pending` rows
// broadcast-created β†’ all contacts pushed; broadcast object exists in Resend; send may have failed
// sent β†’ terminal success β€” broadcastRampConfig advanced atomically by _finalizeWaveRun
// failed β†’ terminal-by-failure; substatus carries reason and dictates which operator
// recovery mutation applies (resumeStalledWaveRun, resumeFinalizeWaveRun,
// markFinalizeRecovered, or discardWaveRun)
status: v.union(
v.literal("picking"),
v.literal("segment-created"),
v.literal("pushing"),
v.literal("broadcast-created"),
v.literal("sent"),
v.literal("failed"),
),
// Operator-supplied count from `pickWaveAction` args. May exceed pool β€”
// the actual picked count is in `totalCount`, with `underfilled=true`.
requestedCount: v.number(),
// = picked.length after reservoir sampling. Finalization gates on
// "zero `pending` rows for this runId", NOT on pushedCount === totalCount β€”
// failed contacts are tolerated up to the 5% threshold.
totalCount: v.number(),
underfilled: v.boolean(),
pushedCount: v.number(),
failedCount: v.number(),
batchSize: v.number(),
// Updated by every successful batch + by lease-revalidating recovery
// mutations. Used (with createdAt/updatedAt fallback) by `runDailyRamp`'s
// 15-min in-flight guard to distinguish "actively running" from "stalled
// β€” needs operator intervention".
lastBatchAt: v.optional(v.number()),
broadcastId: v.optional(v.string()),
// Discriminator for `failed` status. Drives operator recovery routing:
// 'create-broadcast-failed' β†’ segment ready, no broadcast yet β†’ resumeFinalizeWaveRun retries create
// 'send-broadcast-failed' β†’ segment + broadcast ready, send failed β†’ resumeFinalizeWaveRun({confirmedNotSent:true}) OR markFinalizeRecovered
// 'discarded-by-operator' β†’ discardWaveRun ran; cleanup cron prunes the rows
// 'batch-failure-rate-exceeded' β†’ push-side >5% failures β†’ discardWaveRun (transient retry won't help)
// 'empty-pool' β†’ pickWave found zero unstamped registrations β†’ terminal no-op
// 'segment-create-failed' β†’ Resend createSegment failed β†’ operator inspects + discards
// 'persist-failed' β†’ mid-loop _persistPickedBatch failed β†’ operator inspects + discards
failureSubstatus: v.optional(v.string()),
error: v.optional(v.string()),
// Pool-filter audit fields (added 2026-05-10 alongside `users` table +
// `excludeNonEnglish` flag). Populated by pickWaveAction's pool selection
// step so any past wave's filter behavior is auditable from the
// `waveRuns` row alone β€” no log archaeology required. Optional so
// pre-existing rows pass schema validation.
excludeNonEnglish: v.optional(v.boolean()),
eligiblePoolCount: v.optional(v.number()),
excludedCount: v.optional(v.number()),
excludedLocaleCounts: v.optional(v.record(v.string(), v.number())),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_runId", ["runId"])
.index("by_status", ["status"]),
// Per-contact tri-state row written by `_persistPickedBatch` during pick
// and patched atomically by `_markContactPushed` / `_markContactFailed`
// during push. The CAS guard on those mutations (no-op unless
// status==='pending') makes them idempotent under overlapping
// pushBatchAction invocations or operator-resume-while-original-still-running.
//
// Rows are NOT deleted synchronously on `discardWaveRun` β€” the daily
// `cleanupDiscardedWavePickedContactsAction` cron prunes them in 500-row
// batches to avoid hitting Convex's per-mutation write limits on bulk
// deletion of up to 25k rows.
wavePickedContacts: defineTable({
runId: v.string(),
normalizedEmail: v.string(),
status: v.union(
v.literal("pending"),
v.literal("pushed"),
v.literal("failed"),
),
pushedAt: v.optional(v.number()),
failedAt: v.optional(v.number()),
failedReason: v.optional(v.string()),
})
.index("by_runId", ["runId"])
.index("by_runId_status", ["runId", "status"]),
// Phase 9 / Todo #223 β€” Clerk-user referral codes.
// The `registrations.referralCode` column uses a 6-char hash of
// the registering email; share-button codes are an 8-char HMAC
// of the Clerk userId. Distinct spaces β€” this table resolves the
// Clerk-code space back to a userId so the register mutation can
// credit the right sharer when their code is used.
userReferralCodes: defineTable({
userId: v.string(),
code: v.string(),
createdAt: v.number(),
})
.index("by_user", ["userId"])
.index("by_code", ["code"]),
// Attribution rows written when a /pro?ref=<clerkCode> visitor
// signs up for the waitlist. One row per (referrer, referee email)
// pair. Kept separate from `registrations.referralCount` because
// the referrer has no registrations row to increment.
userReferralCredits: defineTable({
referrerUserId: v.string(),
refereeEmail: v.string(),
createdAt: v.number(),
})
.index("by_referrer", ["referrerUserId"])
.index("by_referrer_email", ["referrerUserId", "refereeEmail"]),
contactMessages: defineTable({
name: v.string(),
email: v.string(),
organization: v.optional(v.string()),
phone: v.optional(v.string()),
message: v.optional(v.string()),
source: v.string(),
receivedAt: v.number(),
normalizedEmail: v.optional(v.string()),
}).index("by_normalized_email_received", ["normalizedEmail", "receivedAt"]),
counters: defineTable({
name: v.string(),
value: v.number(),
}).index("by_name", ["name"]),
// --- Payment tables (Dodo Payments integration) ---
subscriptions: defineTable({
userId: v.string(),
dodoSubscriptionId: v.string(),
dodoProductId: v.string(),
planKey: v.string(),
status: subscriptionStatus,
currentPeriodStart: v.number(),
currentPeriodEnd: v.number(),
cancelledAt: v.optional(v.number()),
// Stable first-class projection of `rawPayload.customer.customer_id`
// (the Dodo customer this sub was paid as). Optional because
// `DodoSubscriptionData.customer` is itself optional and lifecycle
// event payloads (`subscription.renewed`, `.on_hold`, `.cancelled`,
// `.plan_changed`, `.expired`) sometimes arrive without it β€” a
// blind `rawPayload: data` patch would otherwise wipe the value.
// Webhook handlers write this field with `data.customer?.customer_id
// ?? existing.dodoCustomerId` (see `mergeDodoCustomerId` in
// `subscriptionHelpers.ts`) so it survives lifecycle patches.
//
// Manage Billing prefers this column when populated β€” see
// `payments/billing:getDodoCustomerIdForUserPortal`, which is a
// 3-tier resolver (this column β†’ `rawPayload.customer.customer_id`
// β†’ `customers.dodoCustomerId` for the same userId). Pre-PR rows
// may still rely on tiers 2-3 until
// `backfillSubscriptionDodoCustomerId` lands their values here.
dodoCustomerId: v.optional(v.string()),
// Epoch ms of the event that opened the CURRENT on_hold episode.
// Set by handleSubscriptionOnHold only on the active→on_hold
// transition (webhook replays while already on_hold keep the
// original anchor), and used as the dunning episode key (#4932):
// day-3/day-7 reminders compute their age from it, and the
// dunningEmails ledger scopes idempotency to it so a NEW payment
// failure months later starts a fresh email sequence. Optional β€”
// rows that entered on_hold before this field existed fall back
// to `updatedAt` in the dunning scan.
onHoldAt: v.optional(v.number()),
rawPayload: v.any(),
updatedAt: v.number(),
// Renewal-reconciliation bookkeeping (see
// `payments/billing:reconcileMissedDodoRenewals`). Orthogonal to
// `updatedAt` β€” these are NEVER bumped on a webhook state change, only
// when the reconciliation cron attempts (and fails/skips) a row. Used to
// back off permanently-failing rows (e.g. test-mode-era subs that 404
// against the live Dodo client) so they stop starving the batch's scan
// slots. Cleared on a successful reconcile AND on a webhook that renews the
// sub (so a new stale episode starts from a clean slate).
lastReconcileAttemptAt: v.optional(v.number()),
reconcileFailureCount: v.optional(v.number()),
// Count of CONSECUTIVE definitive Dodo 404s (reset by any non-404 reconcile
// outcome). Distinct from `reconcileFailureCount` (which counts all failure
// kinds for backoff) so the terminal "subscription deleted in Dodo"
// downgrade requires repeated 404s specifically, not just any prior failure.
reconcileNotFoundCount: v.optional(v.number()),
// Request-path renewal verification (#4770). The state + attempt timestamp
// form a durable lease/cooldown shared by every Convex action instance, so
// concurrent premium requests cannot fan out into duplicate Dodo lookups.
// Kept separate from the daily reconciler's backoff fields above so a cron
// failure does not suppress the bounded customer-facing rescue attempt β€”
// and vice versa: on-demand attempts advance/reset only the shared
// consecutive-404 streak (reconcileNotFoundCount β€” provider evidence
// counts from either path); the backoff pair (reconcileFailureCount /
// lastReconcileAttemptAt) is cron-only, so request-path failures cannot
// defer the nightly safety net (see markDodoReconcileAttempt `source`).
renewalVerificationState: v.optional(v.union(
v.literal("pending"),
v.literal("failed"),
v.literal("lapsed"),
)),
renewalVerificationAttemptAt: v.optional(v.number()),
})
.index("by_userId", ["userId"])
.index("by_userId_status_currentPeriodEnd", ["userId", "status", "currentPeriodEnd"])
.index("by_dodoSubscriptionId", ["dodoSubscriptionId"])
.index("by_dodoCustomerId", ["dodoCustomerId"])
// Dunning scan (#4932): on_hold is a small TRANSIENT set (tens of rows),
// safe to collect() daily.
.index("by_status", ["status"])
// Winback scan (#4932): cancelled is an ACCUMULATING terminal status β€”
// it grows with lifetime churn, so a bare by_status collect() would
// eventually hit Convex's per-transaction read cap and kill the whole
// daily scan (PR #4935 review finding 2). This compound index lets the
// scan range-read only a bounded window. Keyed on currentPeriodEnd
// (ACCESS end), not cancelledAt: an annual subscriber who cancels
// months before expiry would otherwise be paid-through during the
// post-cancel window and outside it once access actually ends β€” never
// winback-eligible (review round 2, finding 3). The winback email says
// "your access ended ~a month ago", so access end is the right clock.
.index("by_status_currentPeriodEnd", ["status", "currentPeriodEnd"]),
// What happened in a Pro-activation session, one row per subscription per
// cohort (see `cohort` below).
//
// For the markerless retro cohort the row is ALSO a cross-device
// single-presentation lease: a short pending claim closes concurrent mount
// races without permanently suppressing onboarding when a browser crashes
// before rendering the flow. The day-0 cohort has no lease β€” its fire-once
// is the browser-local checkout marker β€” so its row is purely the outcome
// record.
//
// The outcome buckets mirror ActivationStepOutcome
// (pro-activation-state.ts). They are updated as the subscriber acts so a
// tab close cannot erase engagement, then frozen when `exitedAt` is set.
// `outcomeRevision` rejects late/out-of-order best-effort writes.
proActivationPresentations: defineTable({
userId: v.string(),
subscriptionId: v.id("subscriptions"),
// Which activation cohort this row records (#5621). ABSENT is the
// markerless retro backfill β€” the only cohort that existed before, so
// every pre-#5621 row reads correctly with no backfill, and the lease
// lookups keep matching them by querying `cohort: undefined`.
// "day0" is the post-checkout welcome session. The two are SEPARATE rows
// for one subscription on purpose: day-0 carries no lease (its fire-once
// is the browser-local checkout marker), so a day-0 row must never occupy
// the retro claim slot or set the `presentedAt` gate that suppresses a
// later legitimate backfill for a subscriber whose day-0 writes all
// failed (#5600).
cohort: v.optional(v.literal("day0")),
claimNonce: v.string(),
claimedAt: v.number(),
// Day-0 only: client-generated session start used with claimNonce as a
// total ownership order. Optional so rows written before the ordered
// takeover contract deploy without a backfill.
sessionStartedAt: v.optional(v.number()),
presentedAt: v.optional(v.number()),
// Set when a presentation is confirmed by an outcome-aware client. This
// excludes rows created before #5582 without losing post-deploy sessions
// that abandon the flow before their first progress snapshot.
outcomeTrackingVersion: v.optional(v.literal(1)),
confirmedSteps: v.optional(v.array(proActivationStepIdValidator)),
skippedSteps: v.optional(v.array(proActivationStepIdValidator)),
// Browser-refused steps (#5617). A separate bucket rather than a marker on
// `skippedSteps` so rows written before it existed stay valid and every
// existing consumer of the original three keeps its exact meaning.
//
// ROLLBACK: once any row has this field populated, deleting this line fails
// the Convex deploy β€” schema validation rejects a stored field the
// validator does not declare. To revert, revert the WRITE path (the client
// and the mutation arg) and leave this field in place; drop it only after
// the surviving rows have aged out.
blockedSteps: v.optional(v.array(proActivationStepIdValidator)),
failedSteps: v.optional(v.array(proActivationStepIdValidator)),
outcomeRevision: v.optional(v.number()),
outcomeUpdatedAt: v.optional(v.number()),
exitedAt: v.optional(v.number()),
})
// Cohort is part of the key so each lookup names the row it means. A
// prefix query on `subscriptionId` alone still reads BOTH cohorts (used
// by subscription deletion); every lease/outcome lookup pins the second
// component so it can never cross cohorts.
.index("by_subscription_cohort", ["subscriptionId", "cohort"]),
// Dunning/winback send ledger (#4932): one row per email step actually
// delivered for a given subscription episode. `episodeAt` is the on_hold
// anchor (dunning steps) or `cancelledAt` (winback), so a later, separate
// payment-failure episode legitimately re-sends the sequence while webhook
// replays and overlapping cron ticks stay idempotent. Growth is bounded by
// real billing events (≀4 rows per episode), so no prune cron is needed.
dunningEmails: defineTable({
dodoSubscriptionId: v.string(),
step: v.union(
v.literal("dunning_day0"),
v.literal("dunning_day3"),
v.literal("dunning_day7"),
v.literal("winback_day30"),
),
episodeAt: v.number(),
email: v.string(),
sentAt: v.number(),
}).index("by_sub_step_episode", ["dodoSubscriptionId", "step", "episodeAt"]),
entitlements: defineTable({
userId: v.string(),
planKey: v.string(),
features: v.object({
tier: v.number(),
maxDashboards: v.number(),
apiAccess: v.boolean(),
apiRateLimit: v.number(),
planLimits: v.optional(v.object({
apiRequestsPerDay: v.union(v.number(), v.null()),
apiBurstRequestsPerMinute: v.union(v.number(), v.null()),
mcpCallsPerDay: v.union(v.number(), v.null()),
mcpBurstRequestsPerMinute: v.union(v.number(), v.null()),
})),
prioritySupport: v.boolean(),
exportFormats: v.array(v.string()),
// Optional for backward-compat with existing rows written before
// plan 2026-05-10-001 (Pro MCP). Dodo webhooks repopulate this on
// the next subscription event; legacy rows return undefined and
// every consumer treats undefined as "no MCP access" (fail-closed).
mcpAccess: v.optional(v.boolean()),
// Optional β€” per-account daily REST allowance (#3199). Legacy rows
// predate it; the rate-limit consumer treats undefined as "no daily
// limit" (fail-OPEN). Catalog-sourced writes always set it, so this
// validator MUST accept it or the webhook's entitlement write is
// rejected (v.object is strict on extra keys).
apiDailyAllowance: v.optional(v.number()),
// Optional β€” data-export entitlement (plan 2026-07-25-001). Legacy rows
// predate it; consumers treat undefined on a tier >= 2 row as entitled
// (fail-OPEN, permanently β€” see the PlanFeatures JSDoc). Catalog-sourced
// writes always set it, so this validator MUST accept it.
dataExport: v.optional(v.boolean()),
}),
validUntil: v.number(),
// Optional complimentary-entitlement floor. When set and in the future,
// subscription.expired events skip the normal downgrade-to-free so
// goodwill credits outlive Dodo subscription cancellations.
compUntil: v.optional(v.number()),
updatedAt: v.number(),
})
.index("by_userId", ["userId"])
.index("by_validUntil", ["validUntil"]),
apiUsageRollups: defineTable({
userId: v.string(),
planKey: v.string(),
dimension: apiPlanLimitDimension,
windowKey: v.string(),
windowStart: v.number(),
windowEnd: v.number(),
limit: v.union(v.number(), v.null()),
usage: v.number(),
usageRatio: v.union(v.number(), v.null()),
source: v.string(),
sourceFreshAt: v.number(),
computedAt: v.number(),
})
.index("by_user_window", ["userId", "windowKey"])
.index("by_window_dimension", ["windowKey", "dimension"])
// Age-ordered for the retention prune cron (burst mints one rollup per
// user per hourly scan, so this table grows without bound otherwise).
.index("by_computedAt", ["computedAt"]),
apiPlanLimitNotices: defineTable({
userId: v.string(),
planKey: v.string(),
dimension: apiPlanLimitDimension,
state: apiPlanLimitNoticeState,
windowKey: v.string(),
usage: v.number(),
limit: v.union(v.number(), v.null()),
usageRatio: v.union(v.number(), v.null()),
current: v.boolean(),
firstSeenAt: v.number(),
lastSeenAt: v.number(),
lastEmailedAt: v.optional(v.number()),
acknowledgedAt: v.optional(v.number()),
emailStatus: apiPlanLimitEmailStatus,
// Number of delivery attempts that ended in `failed`. Bounds retries so a
// permanently undeliverable recipient stops being re-sent on every scan.
emailAttempts: v.optional(v.number()),
upgradeTargetPlanKey: v.optional(v.string()),
ctaKind: apiPlanLimitCtaKind,
blockedReason: v.optional(v.string()),
})
.index("by_notice_dedupe", ["userId", "planKey", "dimension", "state", "windowKey"])
// `current` first so listEmailDue can exclude superseded rows in the index
// (not a post-take filter) -- a dead-pending backlog can't starve live due notices.
.index("by_email_due", ["current", "emailStatus", "lastSeenAt"])
// Only-`current` scans (readiness gate + stale-notice recovery sweep) query
// through this index instead of collecting the whole (ever-growing) table.
.index("by_current", ["current", "lastSeenAt"])
// Per-user live-notice lookups (supersede loop, recovery clear, Settings
// list) query this instead of scanning all per-(user,state) history and
// filtering `current` in memory -- bounds the hot path to live rows.
.index("by_user_dimension_current", ["userId", "dimension", "current"]),
customers: defineTable({
userId: v.string(),
dodoCustomerId: v.optional(v.string()),
email: v.string(),
// Lowercased + trimmed mirror of `email`. Required for O(1) joins from
// `registrations`/`emailSuppressions` (both keyed on `normalizedEmail`)
// when building broadcast audiences β€” without this, dedup is a full
// table scan and paid users can leak into "buy PRO!" sends.
// Optional so existing rows pass schema validation; backfilled via
// `npx convex run payments/backfillCustomerNormalizedEmail:backfill`.
normalizedEmail: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_userId", ["userId"])
.index("by_dodoCustomerId", ["dodoCustomerId"])
.index("by_normalized_email", ["normalizedEmail"]),
// Canonical per-Clerk-user record. Populated on first authenticated session
// by client β†’ `users:ensureRecord` (see convex/users.ts). Distinct from
// `customers` (which is paid-only, populated by Dodo subscription webhook):
// `users` covers EVERY Clerk-authenticated user, free or paid. Holds
// operational properties used for product personalization and broadcast
// audience filtering β€” locale, timezone, country, first/last seen.
//
// ⚠️ Authority of `country`: client-reported (derived from a `cf-ipcountry`
// cookie or similar). NOT authoritative. Do NOT use for compliance, geo-
// gating, or anything where a malicious client could spoof a different
// country to gain or evade something. Server-side derivation (Vercel edge
// wrapper reading `cf-ipcountry` from the actual request headers) is a
// future v2 concern; v1 just stores what the client passes for analytics
// use only.
users: defineTable({
userId: v.string(), // Clerk userId; primary identifier
email: v.optional(v.string()), // Server-derived from ctx.auth.getUserIdentity()
normalizedEmail: v.optional(v.string()), // Lowercased mirror of email; joined against registrations
localeTag: v.optional(v.string()), // Full BCP 47 tag (e.g. "zh-CN", "en-US"); kept for future analytics
localePrimary: v.optional(v.string()), // Lowercased primary subtag (e.g. "zh", "en"); broadcast filter target
timezone: v.optional(v.string()), // IANA zone (e.g. "Asia/Shanghai")
country: v.optional(v.string()), // ISO 3166-1 alpha-2; CLIENT-REPORTED β€” see warning above
firstSeenAt: v.number(),
lastSeenAt: v.number(),
})
.index("by_userId", ["userId"])
.index("by_normalizedEmail", ["normalizedEmail"])
.index("by_localePrimary", ["localePrimary"]),
webhookEvents: defineTable({
webhookId: v.string(),
eventType: v.string(),
rawPayload: v.any(),
processedAt: v.number(),
status: v.literal("processed"),
})
.index("by_webhookId", ["webhookId"])
.index("by_eventType", ["eventType"]),
// Durable dead-letter records for Dodo events that fail processing. Keep
// this projection intentionally payload-free: operators need stable Dodo
// identifiers and shape metadata to repair a subscription/payment, not a
// second copy of customer data or webhook secrets.
paymentWebhookFailures: defineTable({
webhookId: v.string(),
eventType: v.string(),
dodoSubscriptionId: v.optional(v.string()),
dodoPaymentId: v.optional(v.string()),
dodoCustomerId: v.optional(v.string()),
errorKind: v.string(),
errorMessage: v.string(),
dataKeys: v.array(v.string()),
eventTimestamp: v.number(),
receivedAt: v.number(),
lastSeenAt: v.number(),
attemptCount: v.number(),
unresolved: v.boolean(),
resolvedAt: v.optional(v.number()),
resolvedBy: v.optional(v.string()),
resolutionNote: v.optional(v.string()),
})
.index("by_webhookId", ["webhookId"])
.index("by_unresolved_lastSeenAt", ["unresolved", "lastSeenAt"])
.index("by_dodoSubscriptionId", ["dodoSubscriptionId"])
.index("by_dodoPaymentId", ["dodoPaymentId"]),
// Bounded aggregate used for the Sentry/ops signal. Keeping it separate
// from the dead-letter rows avoids collecting an incident-sized table from
// every retry just to report queue counts. The pre-seeded global document
// also serializes failure-row inserts and lifecycle transitions; see
// `payments/webhookMutations:_seedFailureSummary` and the Convex deploy
// workflow. It must not be lazily created in the failure mutation because
// an empty index range does not serialize concurrent first inserts.
paymentWebhookFailureSummary: defineTable({
key: v.literal("global"),
unresolvedCount: v.number(),
eventTypes: v.array(
v.object({
eventType: v.string(),
count: v.number(),
}),
),
updatedAt: v.number(),
}).index("by_key", ["key"]),
paymentEvents: defineTable({
userId: v.string(),
dodoPaymentId: v.string(),
type: v.union(v.literal("charge"), v.literal("refund")),
amount: v.number(),
currency: v.string(),
status: paymentEventStatus,
dodoSubscriptionId: v.optional(v.string()),
// Plan key (e.g. "pro_monthly") threaded through the checkout-session
// metadata bridge (metadata.wm_plan_key) so a pending 3DS payment row can be
// resolved to its PRODUCT_CATALOG tierGroup for the duplicate-payment guard
// (#4438). Optional: legacy rows and sessions created before the bridge
// shipped simply have none (the guard fails open for those β€” see #4438 plan).
planKey: v.optional(v.string()),
rawPayload: v.any(),
occurredAt: v.number(),
})
.index("by_userId", ["userId"])
.index("by_dodoPaymentId", ["dodoPaymentId"])
.index("by_occurredAt", ["occurredAt"])
// Time-bounded read for the duplicate-payment guard (#4438): it only needs
// recent rows (within the staleness window), so it queries this index with a
// range on occurredAt instead of collecting the user's whole (unbounded,
// rawPayload-carrying) payment history β€” keeps the guard fail-open.
.index("by_userId_occurredAt", ["userId", "occurredAt"]),
paymentReconciliationAttempts: defineTable({
dodoPaymentId: v.string(),
userId: v.string(),
planKey: v.optional(v.string()),
action: v.union(
v.literal("terminal_reconciled"),
v.literal("customer_notified"),
v.literal("ops_notified"),
),
observedStatus: v.string(),
pendingOccurredAt: v.number(),
reconciledAt: v.number(),
})
.index("by_dodoPaymentId", ["dodoPaymentId"])
.index("by_reconciledAt", ["reconciledAt"]),
productPlans: defineTable({
dodoProductId: v.string(),
planKey: v.string(),
displayName: v.string(),
isActive: v.boolean(),
})
.index("by_dodoProductId", ["dodoProductId"])
.index("by_planKey", ["planKey"]),
userApiKeys: defineTable({
userId: v.string(),
name: v.string(),
keyPrefix: v.string(), // first 8 chars of plaintext key, for display
keyHash: v.string(), // SHA-256 hex digest β€” never store plaintext
createdAt: v.number(),
lastUsedAt: v.optional(v.number()),
revokedAt: v.optional(v.number()),
})
.index("by_userId", ["userId"])
.index("by_keyHash", ["keyHash"]),
// Non-key Pro MCP identity rows. One row per OAuth grant for a Pro user.
// Referenced from OAuth code/token records as `mcpTokenId` β€” never carries
// plaintext or `wm_` keys. Revoke deletes the row's revokedAt β†’ next
// bearer-resolution at api/mcp.ts returns 401 (no token-index sweep needed).
// See plan: docs/plans/2026-05-10-001-feat-pro-mcp-clerk-auth-quota-plan.md
mcpProTokens: defineTable({
userId: v.string(),
clientId: v.optional(v.string()),
name: v.optional(v.string()),
createdAt: v.number(),
lastUsedAt: v.optional(v.number()),
revokedAt: v.optional(v.number()),
}).index("by_userId", ["userId"]),
// API Business domain-gated Pro-seat invites (#4634/#4635). One row per seat
// invite issued by an active `api_business` owner to a same-corporate-domain
// teammate. The grant is an explicit, revocable object keyed to the owner's
// Business `dodoSubscriptionId` (KTD1) β€” NOT a fake subscription β€” so
// `pickBestCoveringSub` stays clean. An `accepted` grant under a covering
// Business sub resolves the invitee to Pro (U5); when the Business sub stops
// covering, its grants flip to `revoked` and each invitee recomputes down (U6).
// `inviteeEmail`/`domain` are stored lowercased for exact same-domain checks.
businessProGrants: defineTable({
businessSubscriptionId: v.string(),
ownerUserId: v.string(),
inviteeEmail: v.string(),
domain: v.string(),
status: v.union(
v.literal("pending"),
v.literal("accepted"),
v.literal("revoked"),
v.literal("expired"),
),
inviteeUserId: v.optional(v.string()),
createdAt: v.number(),
acceptedAt: v.optional(v.number()),
expiresAt: v.number(),
})
.index("by_businessSubscriptionId", ["businessSubscriptionId"])
.index("by_inviteeEmail", ["inviteeEmail"])
.index("by_inviteeUserId", ["inviteeUserId"]),
// Per-Business-subscription serialization document for the 4-seat cap.
// EVERY mutation that mutates `businessProGrants` for a Business sub reads
// AND writes this row, forcing Convex's per-document OCC to serialize
// concurrent inviteSeats / removeSeat calls. Without this, two parallel
// invites could both pass the cap check and insert a 5th grant.
businessSeatLocks: defineTable({
businessSubscriptionId: v.string(),
lastTouchedAt: v.number(),
}).index("by_businessSubscriptionId", ["businessSubscriptionId"]),
emailSuppressions: defineTable({
normalizedEmail: v.string(),
reason: v.union(v.literal("bounce"), v.literal("complaint"), v.literal("manual")),
suppressedAt: v.number(),
source: v.optional(v.string()),
}).index("by_normalized_email", ["normalizedEmail"]),
// Per-event log of Resend webhook deliveries tagged with a broadcast_id.
// Used as forensic detail to drive engineer-level inspection alongside
// Resend's dashboard. Idempotent on `webhookEventId` β€” Resend retries
// on 5xx and we MUST treat every delivery as at-most-once.
//
// No recipient email stored, AND no rawPayload stored β€” Resend's
// `data` object includes `to: string[]` (recipient addresses), `from`,
// `subject`, etc. that are PII or PII-adjacent. Convex dashboard rows
// are observable to anyone with project access. We keep only the
// identifying metadata; if a specific event needs deeper inspection,
// look it up by `emailMessageId` in the Resend dashboard.
broadcastEvents: defineTable({
webhookEventId: v.string(),
broadcastId: v.string(),
emailMessageId: v.optional(v.string()),
eventType: v.string(),
occurredAt: v.number(),
})
.index("by_webhookEventId", ["webhookEventId"])
.index("by_broadcast_event", ["broadcastId", "eventType"]),
// Pre-seeded, document-backed serialization point for `intelHistory.append`.
//
// Convex does not treat an empty `by_dedupeKey` index range as a conflict
// dependency, so concurrent first-seen appends could both see no row and
// insert the same key. `intelHistory.append` reads and patches this
// always-existing singleton before checking dedupe keys, making the OCC
// dependency document-backed. The historical seeders are low-frequency,
// so one global serialization point is intentional and keeps the invariant
// simple. It is seeded by the deploy workflow; append fails loudly if it is
// absent rather than silently weakening idempotency.
intelHistoryAppendLocks: defineTable({
lockKey: v.string(),
lastTouchedAt: v.number(),
}).index("by_lockKey", ["lockKey"]),
// Append-only historical intelligence memory (#5694). Seeders publish a
// rolling live snapshot to Redis that overwrites itself every run; this
// table is the durable long tail behind it β€” one row per distinct event,
// never updated in place. `dedupeKey` is the seeder-side identity of an
// event, so a re-publish of the same event is a skip, not a second row
// (see `append` in convex/intelHistory.ts).
//
// EMBEDDING CONTRACT β€” `embedding` is produced by
// openai/text-embedding-3-small at 512 dimensions: the SAME model and
// dimension pair the brief deduper uses (EMBED_MODEL / EMBED_DIMS in
// scripts/lib/brief-dedup-consts.mjs). The vector index below hard-codes
// `dimensions: 512` and Convex rejects a stored vector of any other length,
// so changing the model OR the dimension is a table migration (a new /
// version-suffixed table plus a full re-embed) β€” NOT an in-place edit of
// this number. Mixing vectors from two models in one index is worse than a
// hard failure: the search still returns results, they are just ranked
// against a similarity scale that no longer means anything. The deduper
// carries the same warning on its CACHE_VERSION prefix.
intelHistory: defineTable({
// "conflict" | "military" | "energy" today. Deliberately v.string() and
// not a v.union of literals: a new seeder domain should be a code change
// in the collector, not a schema deploy that has to land first.
domain: v.string(),
resource: v.string(),
country: v.optional(v.string()),
category: v.optional(v.string()),
title: v.string(),
summary: v.optional(v.string()),
sourceUrl: v.optional(v.string()),
// Event time as reported by the source, vs. the time we stored it. Both
// are kept: reads are ordered by `occurredAt` (what a user means by "what
// happened last week") while retention ages rows out by `ingestedAt` (so
// a backfill of old events is not deleted by the next prune tick).
occurredAt: v.number(),
ingestedAt: v.number(),
runId: v.string(),
dedupeKey: v.string(),
embedding: v.array(v.float64()),
})
.index("by_dedupeKey", ["dedupeKey"])
.index("by_ingestedAt", ["ingestedAt"])
.index("by_domain_occurredAt", ["domain", "occurredAt"])
.index("by_country_occurredAt", ["country", "occurredAt"])
// Convex's vector-index filter builder supports only `eq` and `or` β€” there
// is no `and`. A query scoped to BOTH domain and country therefore pushes
// one field down and post-filters the other; see `search` in
// convex/intelHistory.ts for which one and why.
.vectorIndex("by_embedding", {
vectorField: "embedding",
dimensions: 512,
filterFields: ["domain", "country"],
}),
// Retraction tombstones for `intelHistory` (#5743).
//
// Deleting a poisoned or wrong history row is not enough on its own. The
// producing seeders republish a rolling window every run, and `append`
// decides "already stored?" by looking for a row with the same `dedupeKey` β€”
// so a bare delete is undone by the next seed tick, usually within the hour.
// A retraction therefore writes a tombstone keyed on the same `dedupeKey`
// the delete removed, and `append` skips any record that matches one.
//
// Tombstones, not a soft-delete flag on `intelHistory`: the row must
// genuinely leave the vector index (that is the whole point of a retraction,
// and a filtered-out row still costs index space and can still be ranked),
// while the identity has to survive it. They age out on the same 180-day
// clock as the history itself β€” by `retractedAt`, so the window starts when
// the operator acted rather than when the event happened.
intelHistoryRetractions: defineTable({
// Matches `intelHistory.dedupeKey` exactly. One row per retracted
// identity; re-retracting the same key refreshes it rather than
// accumulating duplicates.
dedupeKey: v.string(),
// When suppression was last ASSERTED β€” not when the operator first acted.
// `retract` sets it, and every `append` this tombstone suppresses refreshes
// it, because a record still arriving from the producer is evidence the
// feed has not stopped serving it and expiry would be premature. Expiry is
// therefore measured from the producer's last attempt, so `listRetractions`
// orders by "most recently still-live" rather than by when someone typed
// the command. The original action time lives in the `reason` an operator
// is required to supply and in the `intel_history_retracted` breadcrumb.
retractedAt: v.number(),
// Free text from the operator, e.g. "poisoned RSS item, #5743". Required
// at the relay boundary: a tombstone with no stated cause is unreviewable
// six weeks later, when the only question that matters is whether it is
// still deserved.
reason: v.string(),
})
.index("by_dedupeKey", ["dedupeKey"])
.index("by_retractedAt", ["retractedAt"]),
});