File size: 36,807 Bytes
97ee7cb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 | import { ConvexError, v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc } from "./_generated/dataModel";
import {
internalAction,
internalMutation,
internalQuery,
type MutationCtx,
} from "./_generated/server";
/**
* Append-only historical intelligence memory (#5694).
*
* The live seeder snapshots in Redis overwrite themselves every run, so
* "what happened in this country three months ago" is unanswerable from
* them. This module is the durable store behind those snapshots: seeders
* append the events they published, reads come back either chronologically
* (`timeline`) or by semantic similarity (`search`), and a daily cron ages
* rows out (`prune`).
*
* Append-only in the steady state, with one deliberate exception: `retract`
* (#5743) removes named events and tombstones their identity so the producing
* seeder cannot re-add them. Nothing is ever updated in place.
*
* Everything here is `internal*` on purpose. The only ways in are the
* secret-guarded HTTP routes in convex/http.ts β `/relay/intel-history`
* (ingest, Railway seeders), `/relay/intel-history/retract` and
* `/relay/intel-history/restore` (operator-driven retraction),
* `/api/internal-intel-timeline` and `/api/internal-intel-search` (reads,
* Vercel edge). No client-facing function touches this table.
*/
/**
* Dimension of every stored and query vector. MUST equal `EMBED_DIMS` in
* scripts/lib/brief-dedup-consts.mjs (openai/text-embedding-3-small@512) and
* the `dimensions` of the `by_embedding` vector index in convex/schema.ts.
* Changing it is a table migration plus a full re-embed, never an edit here β
* see the schema comment on `intelHistory`.
*/
export const INTEL_HISTORY_EMBED_DIMS = 512;
/**
* Per-call ingest cap. Each record carries a 512-float vector (~4KB before
* encoding), so this bounds both the mutation's write set and the request
* body the relay route has to parse. Seeders chunk larger runs.
*/
export const INTEL_HISTORY_MAX_APPEND_RECORDS = 100;
/**
* Per-call cap on identifiers handed to `retract` / `restore` (#5743).
*
* Retraction is a hand-driven incident operation, not a bulk pipe: an operator
* has identified specific poisoned or wrong rows and is removing them. The cap
* keeps one call's write set bounded and makes a fat-fingered "retract
* everything" fail at the boundary rather than half-succeed.
*/
export const INTEL_HISTORY_MAX_RETRACT_IDENTIFIERS = 100;
/** Bound on one `listRetractions` page β a review read, not a bulk export. */
export const INTEL_HISTORY_MAX_RETRACTION_PAGE = 200;
/**
* Documented retention policy: history older than this is deleted by the
* `intel-history-prune` daily cron. 180 days is the window the historical
* comparisons are specified against ("versus six months ago"); it is also
* what keeps the vector index β the expensive part of this table β bounded.
*/
export const INTEL_HISTORY_RETENTION_DAYS = 180;
const RETENTION_MS = INTEL_HISTORY_RETENTION_DAYS * 24 * 60 * 60 * 1000;
/**
* Per-run delete cap, deliberately far below the apiPlanLimit prune's 500:
* each row here carries a 512-float embedding, so 100 deletes stay well
* inside Convex's per-mutation write limit. A larger backlog drains through
* the self-reschedule below rather than over successive daily runs.
*/
const PRUNE_BATCH = 100;
const TIMELINE_DEFAULT_LIMIT = 50;
export const TIMELINE_MAX_LIMIT = 200;
const SEARCH_DEFAULT_LIMIT = 20;
export const SEARCH_MAX_LIMIT = 64;
/**
* When a read has to post-filter (a field that could not be pushed into the
* index, or an occurredAt range on the vector path), fetch this multiple of
* the caller's limit so the post-filter still has something to return.
* Bounded by TIMELINE_MAX_SCAN / VECTOR_SEARCH_MAX_LIMIT β this widens the
* read, it never unbounds it.
*/
const POST_FILTER_OVERFETCH = 4;
/** Hard ceiling on documents a single `timeline` call may read. */
const TIMELINE_MAX_SCAN = 800;
/** Convex's own ceiling on `vectorSearch` limit. */
const VECTOR_SEARCH_MAX_LIMIT = 256;
/** The single pre-seeded OCC document used to serialize low-frequency appends. */
const APPEND_LOCK_KEY = "intel-history-append";
/**
* Read the pre-seeded append lock or fail closed when deploy initialization
* was skipped. An empty dedupe-key index range is not a document-backed OCC
* dependency, so this document must be read and patched in every append
* transaction before dedupe checks begin.
*/
async function readAppendLockOrThrow(
ctx: MutationCtx,
): Promise<Doc<"intelHistoryAppendLocks">> {
const lock = await ctx.db
.query("intelHistoryAppendLocks")
.withIndex("by_lockKey", (q) => q.eq("lockKey", APPEND_LOCK_KEY))
.first();
if (!lock) {
console.error(
JSON.stringify({
breadcrumb: "intel_history_append_lock_not_seeded",
lockKey: APPEND_LOCK_KEY,
}),
);
throw new ConvexError({ kind: "APPEND_LOCK_NOT_SEEDED" });
}
return lock;
}
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, Math.floor(value)));
}
/**
* Projection returned by every read path.
*
* Two things are deliberately dropped: `embedding` (2KB+ of float noise no
* caller can use, and the single biggest contributor to response size) and
* the raw `_id`, replaced by a plain `id` string so the REST/MCP layers above
* expose a stable opaque handle rather than a Convex document reference.
*
* Absent optionals are simply absent β Convex strips `undefined` object
* fields during serialization rather than emitting nulls.
*/
function projectRecord(doc: Doc<"intelHistory">) {
return {
id: doc._id as string,
domain: doc.domain,
resource: doc.resource,
country: doc.country,
category: doc.category,
title: doc.title,
summary: doc.summary,
sourceUrl: doc.sourceUrl,
occurredAt: doc.occurredAt,
ingestedAt: doc.ingestedAt,
runId: doc.runId,
dedupeKey: doc.dedupeKey,
};
}
export type IntelHistoryRecord = ReturnType<typeof projectRecord>;
/** A `search` hit: the projection plus its cosine similarity to the query. */
export type IntelHistorySearchRecord = IntelHistoryRecord & { _score: number };
/**
* Reject a vector that is the wrong length or carries a non-finite component.
* The length check mirrors what Convex enforces on insert; the finiteness
* check does not exist server-side and matters more: a single NaN silently
* poisons the cosine similarity of every future search that ranks against
* that row, and there is no error to trace it back from.
*/
function assertEmbedding(embedding: number[], context: string): void {
if (embedding.length !== INTEL_HISTORY_EMBED_DIMS) {
throw new Error(
`${context}: embedding must have ${INTEL_HISTORY_EMBED_DIMS} dimensions, got ${embedding.length}`,
);
}
for (const component of embedding) {
if (!Number.isFinite(component)) {
throw new Error(`${context}: embedding components must be finite numbers`);
}
}
}
const appendRecordValidator = v.object({
dedupeKey: 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()),
occurredAt: v.number(),
embedding: v.array(v.float64()),
});
/**
* Append a run's events. Idempotent on `dedupeKey`: a seeder that republishes
* the same event (every run does β the live snapshot is a rolling window)
* adds nothing and reports it as skipped.
*
* The existence check and the insert share this mutation's transaction. Doing
* the check via `ctx.runMutation` would put it in a SEPARATE transaction and
* reopen exactly the race the dedupe is there to close.
*/
export const append = internalMutation({
args: {
domain: v.string(),
resource: v.string(),
runId: v.string(),
records: v.array(appendRecordValidator),
},
handler: async (ctx, args) => {
if (args.records.length > INTEL_HISTORY_MAX_APPEND_RECORDS) {
throw new Error(
`intelHistory.append: at most ${INTEL_HISTORY_MAX_APPEND_RECORDS} records per call, got ${args.records.length}`,
);
}
// Validate the whole batch up front. A throw rolls the transaction back
// either way, but failing before the first write keeps the error about
// the caller's payload rather than about a partially applied run.
for (const rec of args.records) {
assertEmbedding(rec.embedding, "intelHistory.append");
}
// Must precede all dedupe reads. Patching an already-existing document
// makes concurrent first-seen appends conflict and retry against the
// winning transaction's inserted rows.
const appendLock = await readAppendLockOrThrow(ctx);
await ctx.db.patch(appendLock._id, { lastTouchedAt: Date.now() });
const ingestedAt = Date.now();
let inserted = 0;
let skipped = 0;
let retracted = 0;
// Within-batch dedupe: two records sharing a key in one payload would
// both miss the index lookup (neither is committed yet at read time).
const seenInBatch = new Set<string>();
const candidates = [];
for (const rec of args.records) {
if (seenInBatch.has(rec.dedupeKey)) {
skipped += 1;
continue;
}
seenInBatch.add(rec.dedupeKey);
candidates.push(rec);
}
// The existence checks are independent of each other, so issue them
// together: this is up to 100 indexed reads, and running them serially
// holds the mutation's read set open far longer than needed while three
// seeders write to this table on overlapping schedules.
//
// The retraction lookup rides in the same batch. It has to happen HERE,
// inside the append transaction: an operator retracts a row because the
// upstream feed served something poisoned or wrong, and that feed keeps
// serving it β the seeders republish a rolling window, so without this
// check the very next tick finds no row for the dedupeKey and re-inserts
// it. See `retract` below.
const [existing, tombstones] = await Promise.all([
Promise.all(
candidates.map((rec) =>
ctx.db
.query("intelHistory")
.withIndex("by_dedupeKey", (q) => q.eq("dedupeKey", rec.dedupeKey))
.first(),
),
),
Promise.all(
candidates.map((rec) =>
ctx.db
.query("intelHistoryRetractions")
.withIndex("by_dedupeKey", (q) => q.eq("dedupeKey", rec.dedupeKey))
.first(),
),
),
]);
for (const [index, rec] of candidates.entries()) {
// Checked before the existence test so a retracted key is reported as
// retracted rather than folded into the ordinary dedupe count β the two
// are indistinguishable in the row state (both end with nothing written)
// and only this counter tells an operator the tombstone is still doing
// work.
const tombstone = tombstones[index];
if (tombstone) {
// Restart the tombstone's clock. Expiry is what eventually lets a
// retracted identity back in, and the only question that should decide
// it is "has the producer stopped offering this item?" β not "how long
// ago did the operator act?". This record being in THIS run's payload
// is direct evidence the feed is still serving it, so keying expiry on
// the last suppressed attempt makes the tombstone outlive the item by
// construction instead of by a bet on the retention constant. Only
// fires for records an operator actually retracted, so the write cost
// is proportional to tombstones, not to ingest volume.
await ctx.db.patch(tombstone._id, { retractedAt: ingestedAt });
retracted += 1;
continue;
}
if (existing[index] !== null) {
skipped += 1;
continue;
}
await ctx.db.insert("intelHistory", {
domain: args.domain,
resource: args.resource,
runId: args.runId,
country: rec.country,
category: rec.category,
title: rec.title,
summary: rec.summary,
sourceUrl: rec.sourceUrl,
occurredAt: rec.occurredAt,
ingestedAt,
dedupeKey: rec.dedupeKey,
embedding: rec.embedding,
});
inserted += 1;
}
return { inserted, skipped, retracted };
},
});
/**
* Idempotently initialize the document-backed append lock after deployment.
* A missing lock is an operator/deploy failure, never a fallback to unsafe
* index-range-only idempotency; `append` throws APPEND_LOCK_NOT_SEEDED until
* this succeeds.
*/
export const _seedAppendLock = internalMutation({
args: {},
handler: async (ctx): Promise<{ seeded: number }> => {
const existing = await ctx.db
.query("intelHistoryAppendLocks")
.withIndex("by_lockKey", (q) => q.eq("lockKey", APPEND_LOCK_KEY))
.first();
if (existing) return { seeded: 0 };
await ctx.db.insert("intelHistoryAppendLocks", {
lockKey: APPEND_LOCK_KEY,
lastTouchedAt: Date.now(),
});
return { seeded: 1 };
},
});
/**
* Best-effort append-lock touch for the retraction mutations.
*
* `retract` and `restore` write the same identity space `append` reads, so
* they patch the same singleton to inherit its OCC serialization: a concurrent
* append either commits before the retraction (and its row is then deleted) or
* retries after it (and is then skipped by the tombstone). Neither can
* interleave into a re-inserted row that no tombstone covers.
*
* Unlike `append`, a missing lock is NOT fatal here. `append` throws without
* it, so an unseeded deploy has no concurrent writer to serialize against β
* and refusing to retract in that state would block an incident cleanup for a
* race that cannot happen. Log it and proceed.
*/
async function touchAppendLock(ctx: MutationCtx): Promise<void> {
const lock = await ctx.db
.query("intelHistoryAppendLocks")
.withIndex("by_lockKey", (q) => q.eq("lockKey", APPEND_LOCK_KEY))
.first();
if (!lock) {
console.warn(
JSON.stringify({
breadcrumb: "intel_history_retract_without_append_lock",
lockKey: APPEND_LOCK_KEY,
}),
);
return;
}
await ctx.db.patch(lock._id, { lastTouchedAt: Date.now() });
}
/**
* Resolve the caller's identifiers to the set of `dedupeKey`s to act on.
*
* Document ids are what a reader has in hand β every retrieval path projects
* `id` β but `dedupeKey` is what `append` matches on, so an id is only ever a
* way to LOOK UP the key that actually has to be tombstoned. An id that no
* longer resolves is reported back rather than ignored: it usually means the
* row was already pruned or retracted, and an operator acting on a poisoned
* record needs to know which of those it was.
*/
async function resolveRetractionKeys(
ctx: MutationCtx,
args: { ids?: string[]; dedupeKeys?: string[] },
): Promise<{ keys: string[]; unresolvedIds: string[] }> {
const keys = new Set<string>(args.dedupeKeys ?? []);
const unresolvedIds: string[] = [];
const ids = args.ids ?? [];
// normalizeId rather than a v.id() validator: a malformed id typed by an
// operator should come back as "this id did not resolve", not as an opaque
// argument-validation throw from the mutation boundary.
const resolved = await Promise.all(
ids.map(async (raw) => {
const id = ctx.db.normalizeId("intelHistory", raw);
return { raw, doc: id === null ? null : await ctx.db.get(id) };
}),
);
for (const { raw, doc } of resolved) {
if (doc === null) unresolvedIds.push(raw);
else keys.add(doc.dedupeKey);
}
return { keys: [...keys], unresolvedIds };
}
function assertRetractionIdentifierBudget(
args: { ids?: string[]; dedupeKeys?: string[] },
// Named so the message matches the caller's actual argument surface β
// `restore` accepts no ids at all, and telling its caller to "supply ids"
// sends them after an argument the mutation would reject.
accepts = "ids or dedupeKeys",
): void {
const total = (args.ids?.length ?? 0) + (args.dedupeKeys?.length ?? 0);
if (total === 0) {
throw new Error(`intelHistory: at least one of ${accepts} is required`);
}
if (total > INTEL_HISTORY_MAX_RETRACT_IDENTIFIERS) {
throw new Error(
`intelHistory: at most ${INTEL_HISTORY_MAX_RETRACT_IDENTIFIERS} identifiers per call, got ${total}`,
);
}
}
/**
* Remove specific events from the history and keep them out (#5743).
*
* The store is agent-facing and durable for 180 days, so a single poisoned or
* factually wrong feed item is retrievable for far longer than the live
* snapshot that produced it. This is the supported way to take one back
* without a hand-run Convex console operation.
*
* Deletion alone would not hold. `append` treats "no row with this dedupeKey"
* as "never seen", and the producing feed keeps serving the item, so a bare
* delete is reversed by the next seed tick. Each retraction therefore writes a
* tombstone on the `dedupeKey` as well, which `append` consults.
*
* Scoped deliberately narrowly β explicit ids and/or dedupe keys, nothing
* pattern-shaped. A retraction erases evidence from an intelligence archive;
* "delete everything matching this substring" is the wrong amount of power to
* hand a shared relay secret, and the identifiers are cheap to enumerate from
* a search result.
*/
export const retract = internalMutation({
args: {
ids: v.optional(v.array(v.string())),
dedupeKeys: v.optional(v.array(v.string())),
reason: v.string(),
},
handler: async (ctx, args) => {
assertRetractionIdentifierBudget(args);
const reason = args.reason.trim();
if (!reason) {
throw new Error("intelHistory.retract: reason is required");
}
await touchAppendLock(ctx);
const { keys, unresolvedIds } = await resolveRetractionKeys(ctx, args);
// Fail loudly rather than report a successful no-op. A document id is the
// handle an operator copies out of a search result, and a row that was
// already pruned β or already retracted β no longer resolves to one. With
// no key to tombstone the loop below does nothing, and a 200 saying
// `deleted: 0, tombstoned: 0` reads as "already clean" when the truth is
// "nothing was suppressed and the next seed tick will re-add it". The
// recovery is in the message because the operator needs it right there.
if (keys.length === 0) {
throw new Error(
`intelHistory.retract: no identifier resolved to a dedupeKey β nothing was tombstoned. ` +
`Unresolved ids: ${unresolvedIds.join(", ")}. A deleted or pruned row cannot be ` +
`retracted by id; re-run with --dedupe-key to suppress the identity itself.`,
);
}
const retractedAt = Date.now();
let deleted = 0;
let tombstoned = 0;
let refreshed = 0;
for (const dedupeKey of keys) {
const [rows, existingTombstone] = await Promise.all([
// `collect` over the dedupe index rather than `first`: the index is
// meant to hold one row per key, and if an old bug ever put two there
// a retraction that removed only one would leave the poisoned text
// live while reporting success.
ctx.db
.query("intelHistory")
.withIndex("by_dedupeKey", (q) => q.eq("dedupeKey", dedupeKey))
.collect(),
ctx.db
.query("intelHistoryRetractions")
.withIndex("by_dedupeKey", (q) => q.eq("dedupeKey", dedupeKey))
.first(),
]);
for (const row of rows) {
await ctx.db.delete(row._id);
deleted += 1;
}
if (existingTombstone) {
// Re-retracting restarts the tombstone's own 180-day clock. An
// operator repeating the call is telling us the item is still live
// upstream, which is exactly when expiry would be premature.
await ctx.db.patch(existingTombstone._id, { retractedAt, reason });
refreshed += 1;
} else {
await ctx.db.insert("intelHistoryRetractions", {
dedupeKey,
retractedAt,
reason,
});
tombstoned += 1;
}
}
// Deliberately loud, and deliberately naming the identities rather than
// counting them. Retraction removes intelligence from an archive on the
// authority of a shared secret, and the tombstone row β the only other
// record of what was taken out β is deletable by that same credential via
// `restore`. A count cannot answer "which records were removed, and why?"
// six weeks later; the arrays can, and both are already bounded (100 keys
// x 256 chars) by the caller's identifier budget.
console.info(
JSON.stringify({
breadcrumb: "intel_history_retracted",
keys,
deleted,
tombstoned,
refreshed,
unresolvedIds,
reason,
}),
);
return { deleted, tombstoned, refreshed, keys, unresolvedIds };
},
});
/**
* Lift a retraction (#5743). Removes the tombstones so the producing seeder
* may store the event again.
*
* It does NOT resurrect the deleted rows β their embeddings are gone and this
* mutation has no way to recompute one. If the event is still inside the
* seeder's live window it reappears on the next tick; if it is not, the
* retraction is effectively permanent for that row, which is the honest
* outcome and the reason `retract` demands a stated reason up front.
*/
export const restore = internalMutation({
args: {
dedupeKeys: v.array(v.string()),
},
handler: async (ctx, args) => {
assertRetractionIdentifierBudget({ dedupeKeys: args.dedupeKeys }, "dedupeKeys");
await touchAppendLock(ctx);
// De-duplicated like `retract` does. Without it a repeated key deletes its
// tombstone on the first pass and then β read-your-writes inside the
// mutation β finds nothing on the second, so the same call reports the key
// as both removed AND never-retracted.
const dedupeKeys = [...new Set(args.dedupeKeys)];
let removed = 0;
const notRetracted: string[] = [];
for (const dedupeKey of dedupeKeys) {
// The schema intentionally does not claim uniqueness for this index.
// Remove every matching tombstone so legacy or manually introduced
// duplicates cannot leave the key partially retracted.
const tombstones = await ctx.db
.query("intelHistoryRetractions")
.withIndex("by_dedupeKey", (q) => q.eq("dedupeKey", dedupeKey))
.collect();
if (tombstones.length === 0) {
notRetracted.push(dedupeKey);
continue;
}
for (const tombstone of tombstones) {
await ctx.db.delete(tombstone._id);
removed += 1;
}
}
// Names the keys for the same reason `retract` does: this is the operation
// that destroys the other record of what was retracted.
console.info(
JSON.stringify({
breadcrumb: "intel_history_restored",
dedupeKeys,
removed,
notRetracted,
}),
);
return { removed, notRetracted };
},
});
/**
* Review read over the tombstones, newest retraction first.
*
* Retractions are invisible by construction β the whole point is that the row
* is gone and stays gone β so without this the only record of what was taken
* out is a log line that ages out long before the tombstone does.
*/
export const listRetractions = internalQuery({
args: { limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const limit = clamp(
args.limit ?? INTEL_HISTORY_MAX_RETRACTION_PAGE,
1,
INTEL_HISTORY_MAX_RETRACTION_PAGE,
);
// Over-fetch by one to distinguish "that is all of them" from "the page
// filled". Without the flag an operator checking whether a key is still
// suppressed reads a truncated page as an exhaustive one and concludes the
// retraction was lifted β the same `partial` contract `timeline` and
// `search` already return, for the same reason.
const rows = await ctx.db
.query("intelHistoryRetractions")
.withIndex("by_retractedAt")
.order("desc")
.take(limit + 1);
return {
retractions: rows.slice(0, limit).map((row) => ({
dedupeKey: row.dedupeKey,
retractedAt: row.retractedAt,
reason: row.reason,
})),
partial: rows.length > limit,
};
},
});
/**
* Chronological read. At least one of `domain` / `country` is required: the
* only other option would be a table-wide scan ordered by `occurredAt`, which
* has no index and no bounded cost. The REST layer rejects the unscoped case
* too, so this throw is the backstop, not the user-facing error.
*
* `domain` wins the index when both are given, matching `search` below so the
* two read paths agree; `country` is then post-filtered from a window
* over-fetched by POST_FILTER_OVERFETCH. When that candidate window is full,
* `partial` tells callers it may contain more country matches beyond the
* bounded scan; they must not treat a short or empty page as exhaustive.
*/
export const timeline = internalQuery({
args: {
domain: v.optional(v.string()),
country: v.optional(v.string()),
from: v.optional(v.number()),
to: v.optional(v.number()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
if (args.domain === undefined && args.country === undefined) {
throw new Error(
"intelHistory.timeline: at least one of domain or country is required",
);
}
const limit = clamp(args.limit ?? TIMELINE_DEFAULT_LIMIT, 1, TIMELINE_MAX_LIMIT);
const { from, to } = args;
// Over-fetch only when something is left to filter after the index range.
const needsPostFilter = args.domain !== undefined && args.country !== undefined;
const scanLimit = needsPostFilter
? Math.min(limit * POST_FILTER_OVERFETCH, TIMELINE_MAX_SCAN)
: limit;
// Both indexes are (scopeField, occurredAt), so the occurredAt bounds are
// part of the index range rather than a post-read filter. The duplicated
// branches are the price of that: `withIndex` types the range builder
// against the specific index, so one shared helper cannot serve both.
const docs =
args.domain !== undefined
? await ctx.db
.query("intelHistory")
.withIndex("by_domain_occurredAt", (q) => {
const scoped = q.eq("domain", args.domain as string);
if (from !== undefined && to !== undefined) {
return scoped.gte("occurredAt", from).lte("occurredAt", to);
}
if (from !== undefined) return scoped.gte("occurredAt", from);
if (to !== undefined) return scoped.lte("occurredAt", to);
return scoped;
})
.order("desc")
.take(scanLimit)
: await ctx.db
.query("intelHistory")
.withIndex("by_country_occurredAt", (q) => {
const scoped = q.eq("country", args.country as string);
if (from !== undefined && to !== undefined) {
return scoped.gte("occurredAt", from).lte("occurredAt", to);
}
if (from !== undefined) return scoped.gte("occurredAt", from);
if (to !== undefined) return scoped.lte("occurredAt", to);
return scoped;
})
.order("desc")
.take(scanLimit);
const matched = needsPostFilter
? docs.filter((doc) => doc.country === args.country)
: docs;
return {
records: matched.slice(0, limit).map(projectRecord),
partial: needsPostFilter && docs.length === scanLimit,
};
},
});
/**
* Hydrate vector-search hits. `ctx.vectorSearch` returns ids and scores only,
* so the action reads the documents back through this query.
*
* Input order is preserved (the caller's order IS the relevance order) and
* ids that no longer resolve are dropped rather than throwing β a prune tick
* between the search and the hydration is normal, not an error.
*/
export const getByIds = internalQuery({
args: { ids: v.array(v.id("intelHistory")) },
handler: async (ctx, args) => {
if (args.ids.length > VECTOR_SEARCH_MAX_LIMIT) {
throw new Error(
`intelHistory.getByIds: at most ${VECTOR_SEARCH_MAX_LIMIT} ids per call, got ${args.ids.length}`,
);
}
// Independent reads on a user-facing search path β issue them together
// rather than serially. Promise.all resolves in input order, which IS the
// relevance order the caller depends on.
const docs = await Promise.all(args.ids.map((id) => ctx.db.get(id)));
return docs.filter((doc) => doc !== null).map((doc) => projectRecord(doc));
},
});
/**
* Semantic read. Runs as an action because `ctx.vectorSearch` is only
* available there.
*
* FILTER PUSHDOWN β Convex's vector filter builder exposes `eq` and `or` and
* nothing else, so `domain AND country` cannot be expressed. `domain` is
* pushed into the index (matching `timeline`'s index preference so the two
* read paths agree) and `country` is post-filtered, as is the `occurredAt`
* range, which the vector index cannot express at all. Both post-filters run
* after the top-k cut, so the query over-fetches by POST_FILTER_OVERFETCH
* whenever one applies. If that bounded candidate window fills, `partial`
* marks the response as potentially incomplete rather than presenting a
* false-empty or short result as exhaustive. See the final note in
* convex/schema.ts.
*/
export const search = internalAction({
args: {
embedding: v.array(v.float64()),
domain: v.optional(v.string()),
country: v.optional(v.string()),
from: v.optional(v.number()),
to: v.optional(v.number()),
limit: v.optional(v.number()),
minScore: v.optional(v.number()),
},
// The return type is annotated, not inferred. `getByIds` lives in THIS
// module, so `internal.intelHistory.getByIds` resolves through the module's
// own type β inferring the handler's return would be circular (TS7022/7023),
// and TypeScript resolves such cycles by degrading the whole `internal`
// surface to `any`, which silently un-types every OTHER module's
// self-referential `ctx.runQuery` too.
handler: async (
ctx,
args,
): Promise<{ records: IntelHistorySearchRecord[]; partial: boolean }> => {
assertEmbedding(args.embedding, "intelHistory.search");
const limit = clamp(args.limit ?? SEARCH_DEFAULT_LIMIT, 1, SEARCH_MAX_LIMIT);
const pushedDown =
args.domain !== undefined
? "domain"
: args.country !== undefined
? "country"
: null;
const needsPostFilter =
(pushedDown === "domain" && args.country !== undefined) ||
args.from !== undefined ||
args.to !== undefined;
const vectorLimit = Math.min(
needsPostFilter ? limit * POST_FILTER_OVERFETCH : limit,
VECTOR_SEARCH_MAX_LIMIT,
);
const hits = await ctx.vectorSearch("intelHistory", "by_embedding", {
vector: args.embedding,
limit: vectorLimit,
...(pushedDown === "domain"
? { filter: (q) => q.eq("domain", args.domain as string) }
: pushedDown === "country"
? { filter: (q) => q.eq("country", args.country as string) }
: {}),
});
if (hits.length === 0) return { records: [], partial: false };
const scoreById = new Map(hits.map((hit) => [hit._id as string, hit._score]));
const hydrated: IntelHistoryRecord[] = await ctx.runQuery(
internal.intelHistory.getByIds,
{ ids: hits.map((hit) => hit._id) },
);
const records = hydrated
.filter((rec) => {
if (pushedDown === "domain" && args.country !== undefined) {
if (rec.country !== args.country) return false;
}
if (args.from !== undefined && rec.occurredAt < args.from) return false;
if (args.to !== undefined && rec.occurredAt > args.to) return false;
return true;
})
.filter((rec) => {
const score = scoreById.get(rec.id) ?? 0;
return args.minScore === undefined || score >= args.minScore;
})
.slice(0, limit)
.map((rec) => ({ ...rec, _score: scoreById.get(rec.id) ?? 0 }));
return { records, partial: needsPostFilter && hits.length === vectorLimit };
},
});
/**
* Retention prune. Ages rows out by `ingestedAt`, not `occurredAt`, so a
* backfill of genuinely old events survives its first night.
*
* Modelled on `pruneApiPlanLimitData` (convex/apiPlanLimitNotices.ts): the
* per-run batch keeps one invocation inside Convex's write limit, and a full
* batch means more aged rows remain, so the mutation reschedules itself
* immediately β carrying the SAME resolved `now` so the cutoff stays fixed
* across the chain. Without the self-drain, one daily run would delete at
* most `batch` rows and the first prune 180 days after launch would take
* weeks to clear.
*/
export const prune = internalMutation({
args: {
now: v.optional(v.number()),
retentionMs: v.optional(v.number()),
// Per-run delete cap. Optional so tests can drive the drain without
// seeding PRUNE_BATCH rows.
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const now = args.now ?? Date.now();
const cutoff = now - (args.retentionMs ?? RETENTION_MS);
// Floor of 1, and non-finite falls back to the default rather than
// propagating: a batch of 0 makes `take(0)` return [] and the
// `stale.length >= batch` check below read 0 >= 0 as "a full batch", so the
// mutation would reschedule itself forever, deleting nothing. The cron
// passes {}, but this is operator-callable and `limit: 0` is a natural
// thing to type when probing the drain.
const requestedBatch = args.limit;
const batch = Number.isFinite(requestedBatch)
? Math.max(1, Math.floor(requestedBatch as number))
: PRUNE_BATCH;
const stale = await ctx.db
.query("intelHistory")
.withIndex("by_ingestedAt", (q) => q.lt("ingestedAt", cutoff))
.take(batch);
for (const doc of stale) {
await ctx.db.delete(doc._id);
}
// Tombstones age out on the same clock (#5743), measured from when the
// operator retracted rather than when the event happened: a retraction
// only has to outlive the upstream item's presence in the seeder's live
// window, and one retention window past the operator's action is a
// generous bound on that. They drain in the same pass rather than under
// their own cron β there are a handful of them, hand-created, and a second
// scheduled function for that volume is cost with no signal. The row is
// tiny next to a 512-float history row, so the batch budget is unaffected.
// Tombstones use the FULL retention window no matter what the caller
// passed. `retentionMs` is an operator-callable override inherited from
// the apiPlanLimit prune this was modelled on, where shortening it merely
// deletes old rows early β recoverable, and nothing depends on them. Here
// it is a security control: `prune({ retentionMs: 0 })` would set the
// cutoff to `now`, drain EVERY tombstone, and hand every retracted
// identity back to the producing feed on its next tick, silently undoing
// every retraction anyone had ever made. Clamping to the floor lets the
// override only ever LENGTHEN suppression, never shorten it β the same
// shape as the `limit` floor above, for the same reason.
const tombstoneCutoff = now - Math.max(args.retentionMs ?? RETENTION_MS, RETENTION_MS);
const staleRetractions = await ctx.db
.query("intelHistoryRetractions")
.withIndex("by_retractedAt", (q) => q.lt("retractedAt", tombstoneCutoff))
.take(batch);
for (const doc of staleRetractions) {
await ctx.db.delete(doc._id);
}
// Either table filling its batch means more aged rows remain. Each pass
// deletes at least one row from whichever table is still full, so the
// self-drain terminates.
const rescheduled = stale.length >= batch || staleRetractions.length >= batch;
if (rescheduled) {
await ctx.scheduler.runAfter(0, internal.intelHistory.prune, {
now,
retentionMs: args.retentionMs,
limit: batch,
});
}
return {
deleted: stale.length,
deletedRetractions: staleRetractions.length,
rescheduled,
};
},
});
|