@ainyc/canonry 4.34.0 → 4.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,7 @@ import {
8
8
  categoryLabel,
9
9
  determineAnswerMentioned,
10
10
  normalizeProjectDomain
11
- } from "./chunk-XW3F5EEW.js";
11
+ } from "./chunk-XJVYVURK.js";
12
12
 
13
13
  // src/intelligence-service.ts
14
14
  import { eq, desc, asc, and, or, inArray } from "drizzle-orm";
@@ -68,6 +68,7 @@ var projects = sqliteTable("projects", {
68
68
  displayName: text("display_name").notNull(),
69
69
  canonicalDomain: text("canonical_domain").notNull(),
70
70
  ownedDomains: text("owned_domains").notNull().default("[]"),
71
+ aliases: text("aliases").notNull().default("[]"),
71
72
  country: text("country").notNull(),
72
73
  language: text("language").notNull(),
73
74
  tags: text("tags").notNull().default("[]"),
@@ -153,7 +154,12 @@ var querySnapshots = sqliteTable("query_snapshots", {
153
154
  ]);
154
155
  var auditLog = sqliteTable("audit_log", {
155
156
  id: text("id").primaryKey(),
156
- projectId: text("project_id").references(() => projects.id, { onDelete: "cascade" }),
157
+ // SET NULL (not CASCADE) so deleting a project preserves its audit trail.
158
+ // The DELETE /projects route writes a "project.deleted" row immediately
159
+ // before the delete — a CASCADE here would wipe that record before any
160
+ // reader could see it (the deletion would erase the only evidence it
161
+ // happened). Detached rows surface in audit queries with project_id=NULL.
162
+ projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
157
163
  actor: text("actor").notNull(),
158
164
  action: text("action").notNull(),
159
165
  entityType: text("entity_type").notNull(),
@@ -1885,6 +1891,55 @@ var MIGRATION_VERSIONS = [
1885
1891
  `CREATE INDEX IF NOT EXISTS idx_snapshots_location ON query_snapshots(location)`,
1886
1892
  `CREATE INDEX IF NOT EXISTS idx_snapshots_created_at ON query_snapshots(created_at)`
1887
1893
  ]
1894
+ },
1895
+ {
1896
+ version: 59,
1897
+ name: "projects-aliases",
1898
+ statements: [
1899
+ `ALTER TABLE projects ADD COLUMN aliases TEXT NOT NULL DEFAULT '[]'`
1900
+ ]
1901
+ },
1902
+ {
1903
+ version: 60,
1904
+ name: "audit-log-preserve-on-project-delete",
1905
+ // The legacy `audit_log.project_id` FK was `ON DELETE CASCADE`, so any
1906
+ // `DELETE /projects/:name` call cascade-wiped every audit row for that
1907
+ // project — including the `project.deleted` row the route handler had
1908
+ // just written in the same path. The deletion erased the only record
1909
+ // that the deletion happened, defeating the entire purpose of the
1910
+ // audit log.
1911
+ //
1912
+ // Fix: rebuild `audit_log` with `project_id` as `ON DELETE SET NULL`.
1913
+ // Existing rows survive verbatim; future deletions detach audit rows
1914
+ // from the project (project_id=NULL) instead of erasing them. SQLite
1915
+ // can't change FK behavior in place — same canonical table-rebuild
1916
+ // pattern v58 used for `query_snapshots`.
1917
+ statements: [
1918
+ `CREATE TABLE IF NOT EXISTS audit_log_v60 (
1919
+ id TEXT PRIMARY KEY,
1920
+ project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
1921
+ actor TEXT NOT NULL,
1922
+ action TEXT NOT NULL,
1923
+ entity_type TEXT NOT NULL,
1924
+ entity_id TEXT,
1925
+ diff TEXT,
1926
+ created_at TEXT NOT NULL
1927
+ )`,
1928
+ // LEFT JOIN guard mirrors v58: if a pre-existing row carries a
1929
+ // dangling project_id (from a pre-FK era or a write with
1930
+ // PRAGMA foreign_keys=OFF), the join nulls it out rather than
1931
+ // failing the migration on the new FK validation.
1932
+ `INSERT INTO audit_log_v60 (
1933
+ id, project_id, actor, action, entity_type, entity_id, diff, created_at
1934
+ )
1935
+ SELECT a.id, p.id, a.actor, a.action, a.entity_type, a.entity_id, a.diff, a.created_at
1936
+ FROM audit_log a
1937
+ LEFT JOIN projects p ON p.id = a.project_id`,
1938
+ `DROP TABLE audit_log`,
1939
+ `ALTER TABLE audit_log_v60 RENAME TO audit_log`,
1940
+ `CREATE INDEX IF NOT EXISTS idx_audit_log_project ON audit_log(project_id)`,
1941
+ `CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at)`
1942
+ ]
1888
1943
  }
1889
1944
  ];
1890
1945
  function isDuplicateColumnError(err) {
@@ -2074,19 +2129,26 @@ function filterTrackedSnapshots(rows) {
2074
2129
  }
2075
2130
 
2076
2131
  // ../intelligence/src/regressions.ts
2132
+ function snapshotKey(snap) {
2133
+ const loc = snap.location ?? "__none__";
2134
+ return JSON.stringify([snap.query, snap.provider, loc]);
2135
+ }
2077
2136
  function detectRegressions(currentRun, previousRun) {
2137
+ if ((currentRun.location ?? null) !== (previousRun.location ?? null)) {
2138
+ return [];
2139
+ }
2078
2140
  const regressions = [];
2079
2141
  const previousCited = /* @__PURE__ */ new Map();
2080
2142
  for (const snap of previousRun.snapshots) {
2081
2143
  if (snap.cited) {
2082
- previousCited.set(`${snap.query}:${snap.provider}`, {
2144
+ previousCited.set(snapshotKey(snap), {
2083
2145
  citationUrl: snap.citationUrl,
2084
2146
  position: snap.position
2085
2147
  });
2086
2148
  }
2087
2149
  }
2088
2150
  for (const snap of currentRun.snapshots) {
2089
- const key = `${snap.query}:${snap.provider}`;
2151
+ const key = snapshotKey(snap);
2090
2152
  if (!snap.cited && previousCited.has(key)) {
2091
2153
  const prev = previousCited.get(key);
2092
2154
  regressions.push({
@@ -2103,16 +2165,23 @@ function detectRegressions(currentRun, previousRun) {
2103
2165
  }
2104
2166
 
2105
2167
  // ../intelligence/src/gains.ts
2168
+ function snapshotKey2(snap) {
2169
+ const loc = snap.location ?? "__none__";
2170
+ return JSON.stringify([snap.query, snap.provider, loc]);
2171
+ }
2106
2172
  function detectGains(currentRun, previousRun) {
2173
+ if ((currentRun.location ?? null) !== (previousRun.location ?? null)) {
2174
+ return [];
2175
+ }
2107
2176
  const gains = [];
2108
2177
  const previousCited = /* @__PURE__ */ new Set();
2109
2178
  for (const snap of previousRun.snapshots) {
2110
2179
  if (snap.cited) {
2111
- previousCited.add(`${snap.query}:${snap.provider}`);
2180
+ previousCited.add(snapshotKey2(snap));
2112
2181
  }
2113
2182
  }
2114
2183
  for (const snap of currentRun.snapshots) {
2115
- const key = `${snap.query}:${snap.provider}`;
2184
+ const key = snapshotKey2(snap);
2116
2185
  if (snap.cited && !previousCited.has(key)) {
2117
2186
  gains.push({
2118
2187
  query: snap.query,
@@ -2173,17 +2242,26 @@ function computeHealthTrend(runs2) {
2173
2242
 
2174
2243
  // ../intelligence/src/causes.ts
2175
2244
  function analyzeCause(regression, currentSnapshots) {
2176
- const currentSnap = currentSnapshots.find(
2177
- (s) => s.query === regression.query && s.provider === regression.provider && !s.cited && s.competitorDomains && s.competitorDomains.length > 0
2245
+ const matchingSnaps = currentSnapshots.filter(
2246
+ (s) => s.query === regression.query && s.provider === regression.provider && !s.cited
2178
2247
  );
2179
- if (currentSnap) {
2180
- const competitor = currentSnap.competitorDomains[0];
2248
+ const withCompetitor = matchingSnaps.find((s) => s.competitorDomains?.length);
2249
+ if (withCompetitor) {
2250
+ const competitor = withCompetitor.competitorDomains[0];
2181
2251
  return {
2182
2252
  cause: "competitor_gain",
2183
2253
  competitorDomain: competitor,
2184
2254
  details: `Competitor ${competitor} now cited for "${regression.query}" on ${regression.provider}`
2185
2255
  };
2186
2256
  }
2257
+ const withCited = matchingSnaps.find((s) => s.citedDomains?.length);
2258
+ if (withCited) {
2259
+ const top = withCited.citedDomains.slice(0, 3);
2260
+ return {
2261
+ cause: "third_party_displacement",
2262
+ details: `${regression.provider} now grounds on ${top.join(", ")} for "${regression.query}" \u2014 none are tracked competitors.`
2263
+ };
2264
+ }
2187
2265
  return {
2188
2266
  cause: "unknown",
2189
2267
  details: `No specific cause identified for loss of "${regression.query}" on ${regression.provider}`
@@ -2626,10 +2704,13 @@ function buildDrivers(input) {
2626
2704
  if (input.action === "create" && input.position === null) {
2627
2705
  drivers.push("no existing page");
2628
2706
  }
2629
- if (input.position !== null && input.position > 30) {
2630
- drivers.push(`page ranks #${input.position} (effectively invisible)`);
2631
- } else if (input.position !== null && input.position > 10) {
2632
- drivers.push(`page ranks #${input.position}`);
2707
+ if (input.position !== null) {
2708
+ const positionDisplay = Math.round(input.position);
2709
+ if (input.position > 30) {
2710
+ drivers.push(`page ranks #${positionDisplay} (effectively invisible)`);
2711
+ } else if (input.position > 10) {
2712
+ drivers.push(`page ranks #${positionDisplay}`);
2713
+ }
2633
2714
  }
2634
2715
  if (input.action === "add-schema") {
2635
2716
  drivers.push("cited by LLMs but lacks structured data");
@@ -2900,7 +2981,7 @@ function classifyRegressionSeverity(signals) {
2900
2981
  }
2901
2982
 
2902
2983
  // ../intelligence/src/insight-grouping.ts
2903
- function groupInsights(insights2, keyFn = (i) => `${i.query} ${i.provider} ${i.type}`) {
2984
+ function groupInsights(insights2, keyFn = (i) => JSON.stringify([i.query, i.provider, i.type])) {
2904
2985
  const order = [];
2905
2986
  const buckets = /* @__PURE__ */ new Map();
2906
2987
  for (const i of insights2) {
@@ -2932,14 +3013,15 @@ var MIN_BRAND_TOKEN_LENGTH = 3;
2932
3013
  function compact(value) {
2933
3014
  return value.toLowerCase().replace(/[^a-z0-9]/g, "");
2934
3015
  }
2935
- function buildBrandTokens(canonicalDomain, displayName) {
3016
+ function buildBrandTokens(canonicalDomain, brandNames = []) {
2936
3017
  const seen = /* @__PURE__ */ new Set();
2937
3018
  const stem = canonicalDomain.toLowerCase().replace(/\.[a-z]{2,}$/, "");
2938
3019
  const stemCompact = compact(stem);
2939
3020
  if (stemCompact.length >= MIN_BRAND_TOKEN_LENGTH) seen.add(stemCompact);
2940
- if (displayName) {
2941
- const displayCompact = compact(displayName);
2942
- if (displayCompact.length >= MIN_BRAND_TOKEN_LENGTH) seen.add(displayCompact);
3021
+ for (const name of brandNames) {
3022
+ if (!name) continue;
3023
+ const nameCompact = compact(name);
3024
+ if (nameCompact.length >= MIN_BRAND_TOKEN_LENGTH) seen.add(nameCompact);
2943
3025
  }
2944
3026
  return [...seen];
2945
3027
  }
@@ -3086,7 +3168,7 @@ function extractHostFromUri(uri) {
3086
3168
  }
3087
3169
 
3088
3170
  // ../intelligence/src/mention-landscape.ts
3089
- function buildMentionLandscape(snapshots, competitorDomains, projectDisplayName, projectDomains, queryLookup) {
3171
+ function buildMentionLandscape(snapshots, competitorDomains, projectBrandNames, projectDomains, queryLookup) {
3090
3172
  let projectMentionCount = 0;
3091
3173
  let totalAnswerSnapshots = 0;
3092
3174
  const competitorMap = /* @__PURE__ */ new Map();
@@ -3100,13 +3182,13 @@ function buildMentionLandscape(snapshots, competitorDomains, projectDisplayName,
3100
3182
  const q = queryLookup.byId.get(snap.queryId);
3101
3183
  const projectMentioned = snap.answerMentioned ?? determineAnswerMentioned(
3102
3184
  text2,
3103
- projectDisplayName,
3185
+ [...projectBrandNames],
3104
3186
  [...projectDomains]
3105
3187
  );
3106
3188
  if (projectMentioned) projectMentionCount++;
3107
3189
  for (const competitor of competitorDomains) {
3108
3190
  const brand = brandLabelFromDomain(competitor);
3109
- const mentioned = determineAnswerMentioned(text2, brand, [competitor]);
3191
+ const mentioned = determineAnswerMentioned(text2, brand ? [brand] : [], [competitor]);
3110
3192
  if (mentioned) {
3111
3193
  const entry = competitorMap.get(competitor);
3112
3194
  entry.count++;
@@ -3221,14 +3303,14 @@ function gapTone(gapCount, totalCount) {
3221
3303
 
3222
3304
  // ../intelligence/src/visibility-score.ts
3223
3305
  function buildVisibilityScore(snapshots, options) {
3224
- const tooltip = 'Percentage of tracked queries where your domain is cited by at least one AI answer engine. A query is "visible" if any configured provider includes your site in its response.';
3306
+ const tooltip = "An LLM used a page on your domain as a source for its answer.";
3225
3307
  if (snapshots.length === 0) {
3226
3308
  return {
3227
- label: "Answer Visibility",
3309
+ label: "Citation Coverage",
3228
3310
  value: "No data",
3229
3311
  delta: "Run a sweep first",
3230
3312
  tone: "neutral",
3231
- description: "No visibility data yet. Trigger a run to start tracking.",
3313
+ description: "No citation data yet. Trigger a run to start tracking.",
3232
3314
  tooltip,
3233
3315
  trend: []
3234
3316
  };
@@ -3245,9 +3327,9 @@ function buildVisibilityScore(snapshots, options) {
3245
3327
  const runApiProviderCount = options.configuredApiProviders.filter((p) => runProviders.has(p)).length;
3246
3328
  const isPartialProviderRun = options.configuredApiProviders.length > 1 && runApiProviderCount < options.configuredApiProviders.length;
3247
3329
  return {
3248
- label: "Answer Visibility",
3330
+ label: "Citation Coverage",
3249
3331
  value: `${score}`,
3250
- delta: `${citedCount} of ${totalCount} queries visible`,
3332
+ delta: `${citedCount} of ${totalCount} queries cited`,
3251
3333
  tone: isPartialProviderRun ? "caution" : scoreTone(score),
3252
3334
  description: `${citedCount} of ${totalCount} tracked queries found your domain in at least one AI answer engine.`,
3253
3335
  tooltip,
@@ -3257,6 +3339,44 @@ function buildVisibilityScore(snapshots, options) {
3257
3339
  };
3258
3340
  }
3259
3341
 
3342
+ // ../intelligence/src/mention-coverage.ts
3343
+ function buildMentionCoverage(snapshots, options) {
3344
+ const tooltip = "Your domain or company name was in the answer returned by the LLM.";
3345
+ if (snapshots.length === 0) {
3346
+ return {
3347
+ label: "Mention Coverage",
3348
+ value: "No data",
3349
+ delta: "Run a sweep first",
3350
+ tone: "neutral",
3351
+ description: "No mention data yet. Trigger a run to start tracking.",
3352
+ tooltip,
3353
+ trend: []
3354
+ };
3355
+ }
3356
+ const queryMentioned = /* @__PURE__ */ new Map();
3357
+ for (const snap of snapshots) {
3358
+ if (!queryMentioned.has(snap.queryId)) queryMentioned.set(snap.queryId, false);
3359
+ if (snap.answerMentioned === true) queryMentioned.set(snap.queryId, true);
3360
+ }
3361
+ const totalCount = queryMentioned.size;
3362
+ const mentionedCount = [...queryMentioned.values()].filter(Boolean).length;
3363
+ const score = totalCount > 0 ? Math.round(mentionedCount / totalCount * 100) : 0;
3364
+ const runProviders = new Set(snapshots.map((s) => s.provider));
3365
+ const runApiProviderCount = options.configuredApiProviders.filter((p) => runProviders.has(p)).length;
3366
+ const isPartialProviderRun = options.configuredApiProviders.length > 1 && runApiProviderCount < options.configuredApiProviders.length;
3367
+ return {
3368
+ label: "Mention Coverage",
3369
+ value: `${score}`,
3370
+ delta: `${mentionedCount} of ${totalCount} queries mentioned`,
3371
+ tone: isPartialProviderRun ? "caution" : scoreTone(score),
3372
+ description: `${mentionedCount} of ${totalCount} tracked queries had your brand or domain in the AI answer text.`,
3373
+ tooltip,
3374
+ trend: [],
3375
+ progress: score,
3376
+ providerCoverage: isPartialProviderRun ? `${runApiProviderCount} of ${options.configuredApiProviders.length} providers` : void 0
3377
+ };
3378
+ }
3379
+
3260
3380
  // ../intelligence/src/gap-query-score.ts
3261
3381
  function buildGapQueryScore(snapshots) {
3262
3382
  const tooltip = "Tracked queries where a competitor is cited in the latest run but your domain is not.";
@@ -3474,17 +3594,29 @@ var IntelligenceService = class {
3474
3594
  log.info("intelligence.skip", { runId, reason: "run not in recent completed list" });
3475
3595
  return null;
3476
3596
  }
3477
- const currentRun = this.buildRunData(runId, projectId, currentRunRecord.finishedAt ?? currentRunRecord.createdAt);
3597
+ const currentRun = this.buildRunData(
3598
+ runId,
3599
+ projectId,
3600
+ currentRunRecord.finishedAt ?? currentRunRecord.createdAt,
3601
+ currentRunRecord.location ?? null
3602
+ );
3478
3603
  if (currentRun.snapshots.length === 0) {
3479
3604
  log.info("intelligence.skip", { runId, reason: "no snapshots" });
3480
3605
  return null;
3481
3606
  }
3482
3607
  const orderedRecent = [...recentRuns].reverse();
3483
- const currentIdx = orderedRecent.findIndex((r) => r.id === runId);
3484
- const previousRunRecord = currentIdx > 0 ? orderedRecent[currentIdx - 1] : null;
3485
- const previousRun = previousRunRecord ? this.buildRunData(previousRunRecord.id, projectId, previousRunRecord.finishedAt ?? previousRunRecord.createdAt) : null;
3608
+ const currentLocation = currentRunRecord.location ?? null;
3609
+ const sameLocationOrdered = orderedRecent.filter((r) => (r.location ?? null) === currentLocation);
3610
+ const currentLocIdx = sameLocationOrdered.findIndex((r) => r.id === runId);
3611
+ const previousRunRecord = currentLocIdx > 0 ? sameLocationOrdered[currentLocIdx - 1] : null;
3612
+ const previousRun = previousRunRecord ? this.buildRunData(
3613
+ previousRunRecord.id,
3614
+ projectId,
3615
+ previousRunRecord.finishedAt ?? previousRunRecord.createdAt,
3616
+ previousRunRecord.location ?? null
3617
+ ) : null;
3486
3618
  const trackedCompetitors = this.loadTrackedCompetitors(projectId);
3487
- const history = orderedRecent.slice(0, currentIdx + 1).map((r) => r.id === runId ? currentRun : this.buildRunData(r.id, projectId, r.finishedAt ?? r.createdAt));
3619
+ const history = sameLocationOrdered.slice(0, currentLocIdx + 1).map((r) => r.id === runId ? currentRun : this.buildRunData(r.id, projectId, r.finishedAt ?? r.createdAt, r.location ?? null));
3488
3620
  if (!previousRun) {
3489
3621
  const result2 = analyzeRuns(currentRun, currentRun, { trackedCompetitors, history });
3490
3622
  log.info("intelligence.analyzed", {
@@ -3517,34 +3649,70 @@ var IntelligenceService = class {
3517
3649
  /**
3518
3650
  * Analyze a single run given an explicit previous run (or null for first run).
3519
3651
  * Used by backfill where we control the run ordering.
3652
+ *
3653
+ * `dryRun: true` skips the DB write — `persistResult` is not called and
3654
+ * dismissed flags / health rows are untouched. Callers receive the same
3655
+ * AnalysisResult they would have, suitable for previewing what a write
3656
+ * would have produced.
3520
3657
  */
3521
- analyzeRunWithPrevious(runRecord, previousRunRecord, historyRecords) {
3522
- const currentRun = this.buildRunData(runRecord.id, runRecord.projectId, runRecord.finishedAt ?? runRecord.createdAt);
3658
+ analyzeRunWithPrevious(runRecord, previousRunRecord, historyRecords, opts) {
3659
+ const currentRun = this.buildRunData(
3660
+ runRecord.id,
3661
+ runRecord.projectId,
3662
+ runRecord.finishedAt ?? runRecord.createdAt,
3663
+ runRecord.location ?? null
3664
+ );
3523
3665
  if (currentRun.snapshots.length === 0) {
3524
3666
  return null;
3525
3667
  }
3526
- const previousRun = previousRunRecord ? this.buildRunData(previousRunRecord.id, previousRunRecord.projectId, previousRunRecord.finishedAt ?? previousRunRecord.createdAt) : null;
3668
+ const previousRun = previousRunRecord ? this.buildRunData(
3669
+ previousRunRecord.id,
3670
+ previousRunRecord.projectId,
3671
+ previousRunRecord.finishedAt ?? previousRunRecord.createdAt,
3672
+ previousRunRecord.location ?? null
3673
+ ) : null;
3527
3674
  const trackedCompetitors = this.loadTrackedCompetitors(runRecord.projectId);
3528
- const history = (historyRecords ?? []).map((r) => r.id === runRecord.id ? currentRun : this.buildRunData(r.id, r.projectId, r.finishedAt ?? r.createdAt));
3675
+ const history = (historyRecords ?? []).map((r) => r.id === runRecord.id ? currentRun : this.buildRunData(r.id, r.projectId, r.finishedAt ?? r.createdAt, r.location ?? null));
3529
3676
  if (!previousRun) {
3530
3677
  const result2 = analyzeRuns(currentRun, currentRun, { trackedCompetitors, history });
3531
- this.persistResult(this.emptyAnalysisResult(result2), runRecord.id, runRecord.projectId);
3678
+ const emptyResult = this.emptyAnalysisResult(result2);
3679
+ if (!opts?.dryRun) this.persistResult(emptyResult, runRecord.id, runRecord.projectId);
3532
3680
  return result2;
3533
3681
  }
3534
3682
  const result = analyzeRuns(currentRun, previousRun, { trackedCompetitors, history });
3535
3683
  const tieredResult = this.tierResult(result, runRecord.id, runRecord.projectId);
3536
- this.persistResult(tieredResult, runRecord.id, runRecord.projectId);
3684
+ if (!opts?.dryRun) this.persistResult(tieredResult, runRecord.id, runRecord.projectId);
3537
3685
  return tieredResult;
3538
3686
  }
3539
3687
  /**
3540
3688
  * Backfill intelligence for all completed/partial runs of a project.
3541
3689
  * Processes runs in chronological order so each run compares against its predecessor.
3690
+ *
3691
+ * Scoping options:
3692
+ * - `fromRunId` / `toRunId`: bound the target range by exact run ID.
3693
+ * - `since`: bound the target range by `finishedAt >= <date>`. Accepts
3694
+ * any string that `Date.parse` understands (ISO 8601, `YYYY-MM-DD`,
3695
+ * etc.). Runs before the cutoff are *not* re-processed but stay
3696
+ * available for predecessor lookup, so transition detection at the
3697
+ * boundary stays correct. Composes with `fromRunId` / `toRunId` —
3698
+ * all three filters intersect.
3699
+ * - `dryRun`: compute the analysis without writing. The return value
3700
+ * includes a `delta` describing what would change (rows to delete vs
3701
+ * create per run + aggregate). DB is left untouched.
3542
3702
  */
3543
3703
  backfill(projectName, opts, onProgress) {
3544
3704
  const project = this.db.select().from(projects).where(eq(projects.name, projectName)).get();
3545
3705
  if (!project) {
3546
3706
  throw new Error(`Project "${projectName}" not found`);
3547
3707
  }
3708
+ let sinceTimestamp = null;
3709
+ if (opts?.since !== void 0) {
3710
+ const parsed = Date.parse(opts.since);
3711
+ if (Number.isNaN(parsed)) {
3712
+ throw new Error(`Invalid --since value "${opts.since}": expected a parseable date (ISO 8601 or YYYY-MM-DD)`);
3713
+ }
3714
+ sinceTimestamp = parsed;
3715
+ }
3548
3716
  const allRuns = this.db.select().from(runs).where(
3549
3717
  and(
3550
3718
  eq(runs.projectId, project.id),
@@ -3563,26 +3731,65 @@ var IntelligenceService = class {
3563
3731
  if (idx === -1) throw new Error(`Run "${opts.toRunId}" not found in project`);
3564
3732
  endIdx = idx + 1;
3565
3733
  }
3566
- const targetRuns = allRuns.slice(startIdx, endIdx);
3734
+ let targetRuns = allRuns.slice(startIdx, endIdx);
3735
+ if (sinceTimestamp !== null) {
3736
+ targetRuns = targetRuns.filter((r) => {
3737
+ const ts = r.finishedAt ?? r.createdAt;
3738
+ const t = Date.parse(ts);
3739
+ return !Number.isNaN(t) && t >= sinceTimestamp;
3740
+ });
3741
+ }
3567
3742
  let processed = 0;
3568
3743
  let skipped = 0;
3569
3744
  let totalInsights = 0;
3745
+ const isDryRun = opts?.dryRun === true;
3746
+ const perRunDelta = [];
3747
+ let wouldDeleteTotal = 0;
3748
+ const existingByRunId = /* @__PURE__ */ new Map();
3749
+ if (isDryRun && targetRuns.length > 0) {
3750
+ const rows = this.db.select({ runId: insights.runId }).from(insights).where(inArray(insights.runId, targetRuns.map((r) => r.id))).all();
3751
+ for (const r of rows) {
3752
+ if (r.runId == null) continue;
3753
+ existingByRunId.set(r.runId, (existingByRunId.get(r.runId) ?? 0) + 1);
3754
+ }
3755
+ }
3570
3756
  for (let i = 0; i < targetRuns.length; i++) {
3571
3757
  const run = targetRuns[i];
3572
- const globalIdx = allRuns.indexOf(run);
3573
- const previousRun = globalIdx > 0 ? allRuns[globalIdx - 1] : null;
3574
- const historyStart = Math.max(0, globalIdx - (HISTORY_WINDOW_RUNS - 1));
3575
- const historyRecords = allRuns.slice(historyStart, globalIdx + 1);
3576
- const result = this.analyzeRunWithPrevious(run, previousRun, historyRecords);
3758
+ const runLocation = run.location ?? null;
3759
+ const sameLocationRuns = allRuns.filter((r) => (r.location ?? null) === runLocation);
3760
+ const sameLocIdx = sameLocationRuns.indexOf(run);
3761
+ const previousRun = sameLocIdx > 0 ? sameLocationRuns[sameLocIdx - 1] : null;
3762
+ const historyStart = Math.max(0, sameLocIdx - (HISTORY_WINDOW_RUNS - 1));
3763
+ const historyRecords = sameLocationRuns.slice(historyStart, sameLocIdx + 1);
3764
+ const result = this.analyzeRunWithPrevious(run, previousRun, historyRecords, { dryRun: isDryRun });
3577
3765
  if (result) {
3578
3766
  processed++;
3579
3767
  totalInsights += result.insights.length;
3768
+ if (isDryRun) {
3769
+ const existing = existingByRunId.get(run.id) ?? 0;
3770
+ wouldDeleteTotal += existing;
3771
+ perRunDelta.push({ runId: run.id, existingInsights: existing, newInsights: result.insights.length });
3772
+ }
3580
3773
  onProgress?.({ runId: run.id, index: i + 1, total: targetRuns.length, insights: result.insights.length });
3581
3774
  } else {
3582
3775
  skipped++;
3583
3776
  onProgress?.({ runId: run.id, index: i + 1, total: targetRuns.length, insights: 0 });
3584
3777
  }
3585
3778
  }
3779
+ if (isDryRun) {
3780
+ return {
3781
+ processed,
3782
+ skipped,
3783
+ totalInsights,
3784
+ dryRun: true,
3785
+ delta: {
3786
+ wouldDelete: wouldDeleteTotal,
3787
+ wouldCreate: totalInsights,
3788
+ netChange: totalInsights - wouldDeleteTotal,
3789
+ perRun: perRunDelta
3790
+ }
3791
+ };
3792
+ }
3586
3793
  return { processed, skipped, totalInsights };
3587
3794
  }
3588
3795
  loadTrackedCompetitors(projectId) {
@@ -3729,26 +3936,51 @@ var IntelligenceService = class {
3729
3936
  return { ...insight, severity };
3730
3937
  });
3731
3938
  }
3732
- buildRunData(runId, projectId, completedAt) {
3939
+ buildRunData(runId, projectId, completedAt, location = null) {
3733
3940
  const rows = this.db.select({
3734
3941
  query: queries.query,
3942
+ // Denormalized query text persisted by v58 — the fallback when the
3943
+ // joined queries.query has been hard-deleted (or the query_id was
3944
+ // nulled by the v58 dangling-FK cleanup).
3945
+ queryText: querySnapshots.queryText,
3735
3946
  provider: querySnapshots.provider,
3736
3947
  citationState: querySnapshots.citationState,
3737
3948
  citedDomains: querySnapshots.citedDomains,
3738
- competitorOverlap: querySnapshots.competitorOverlap
3949
+ competitorOverlap: querySnapshots.competitorOverlap,
3950
+ snapshotLocation: querySnapshots.location
3739
3951
  }).from(querySnapshots).leftJoin(queries, eq(querySnapshots.queryId, queries.id)).where(eq(querySnapshots.runId, runId)).all();
3740
- const snapshots = rows.map((r) => {
3952
+ const snapshots = [];
3953
+ let orphanCount = 0;
3954
+ for (const r of rows) {
3955
+ const resolvedQuery = r.query ?? r.queryText ?? null;
3956
+ if (!resolvedQuery) {
3957
+ orphanCount++;
3958
+ continue;
3959
+ }
3741
3960
  const domains = parseJsonColumn(r.citedDomains, []);
3742
3961
  const competitors2 = parseJsonColumn(r.competitorOverlap, []);
3743
- return {
3744
- query: r.query ?? "",
3962
+ snapshots.push({
3963
+ query: resolvedQuery,
3745
3964
  provider: r.provider,
3746
3965
  cited: r.citationState === CitationStates.cited,
3747
3966
  citationUrl: domains[0] ?? void 0,
3748
- competitorDomains: competitors2
3749
- };
3750
- });
3751
- return { runId, projectId, completedAt, snapshots };
3967
+ // Snapshots carry their own location for downstream detectors. In
3968
+ // practice every snapshot in a single runId shares the run's
3969
+ // location; the per-row column is the same value duplicated, but
3970
+ // we read it from the snapshot row so a stale runs.location can't
3971
+ // mask snapshot truth.
3972
+ location: r.snapshotLocation ?? location ?? null,
3973
+ competitorDomains: competitors2,
3974
+ // citedDomains is the FULL set (tracked competitors + third-party
3975
+ // sources). Cause analysis uses it to name the displacing source
3976
+ // when no tracked competitor appears in the response.
3977
+ citedDomains: domains
3978
+ });
3979
+ }
3980
+ if (orphanCount > 0) {
3981
+ log.warn("snapshot.orphan-skip", { runId, projectId, orphanCount });
3982
+ }
3983
+ return { runId, projectId, completedAt, location, snapshots };
3752
3984
  }
3753
3985
  };
3754
3986
 
@@ -3811,6 +4043,7 @@ export {
3811
4043
  buildAiSourceOrigin,
3812
4044
  buildMovementSummary,
3813
4045
  buildVisibilityScore,
4046
+ buildMentionCoverage,
3814
4047
  buildGapQueryScore,
3815
4048
  buildCompetitorPressureScore,
3816
4049
  buildOverviewCompetitors,
@@ -22,7 +22,7 @@ import {
22
22
  trafficConnectVercelRequestSchema,
23
23
  trafficConnectWordpressRequestSchema,
24
24
  trafficEventKindSchema
25
- } from "./chunk-XW3F5EEW.js";
25
+ } from "./chunk-XJVYVURK.js";
26
26
 
27
27
  // src/config.ts
28
28
  import fs from "fs";
@@ -466,6 +466,9 @@ var ApiClient = class {
466
466
  async deleteProject(name) {
467
467
  await this.request("DELETE", `/projects/${encodeURIComponent(name)}`);
468
468
  }
469
+ async previewProjectDelete(name) {
470
+ return this.request("GET", `/projects/${encodeURIComponent(name)}/delete-preview`);
471
+ }
469
472
  async putQueries(project, queries) {
470
473
  await this.request("PUT", `/projects/${encodeURIComponent(project)}/queries`, { queries });
471
474
  }
@@ -1333,6 +1336,17 @@ var canonryMcpTools = [
1333
1336
  openApiOperations: ["GET /api/v1/projects/{name}"],
1334
1337
  handler: (client, input) => client.getProject(input.project)
1335
1338
  }),
1339
+ defineTool({
1340
+ name: "canonry_project_delete_preview",
1341
+ title: "Preview project delete impact",
1342
+ description: "Returns the cascade impact of deleting a project \u2014 how many queries, competitors, runs, snapshots, and insights would be removed, plus how many audit_log rows would be detached (project_id set NULL). Read-only. Use this BEFORE invoking project delete on any project you didn't create yourself; the underlying delete is irreversible.",
1343
+ access: "read",
1344
+ tier: "setup",
1345
+ inputSchema: projectInputSchema,
1346
+ annotations: readAnnotations(),
1347
+ openApiOperations: ["GET /api/v1/projects/{name}/delete-preview"],
1348
+ handler: (client, input) => client.previewProjectDelete(input.project)
1349
+ }),
1336
1350
  defineTool({
1337
1351
  name: "canonry_project_overview",
1338
1352
  title: "Get project overview (composite)",