@gmickel/gno 2.7.0 → 2.8.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.
Files changed (99) hide show
  1. package/README.md +26 -30
  2. package/assets/skill/SKILL.md +8 -1
  3. package/assets/skill/cli-reference.md +8 -1
  4. package/assets/skill/examples.md +2 -1
  5. package/assets/skill/mcp-reference.md +3 -1
  6. package/assets/spa-production.json.gz +0 -0
  7. package/browser-extension/artifacts/{gno-browser-clipper-v2.7.0.zip → gno-browser-clipper-v2.8.0.zip} +0 -0
  8. package/browser-extension/artifacts/gno-browser-clipper-v2.8.0.zip.sha256 +1 -0
  9. package/browser-extension/dist/manifest.json +1 -1
  10. package/package.json +2 -1
  11. package/spec/cli.md +48 -6
  12. package/spec/db/schema.sql +0 -1
  13. package/spec/mcp.md +18 -3
  14. package/spec/output-schemas/audit-report.schema.json +18 -4
  15. package/spec/output-schemas/backlinks.schema.json +4 -0
  16. package/spec/output-schemas/collection-list.schema.json +15 -2
  17. package/spec/output-schemas/graph.schema.json +2 -0
  18. package/spec/output-schemas/links-list.schema.json +4 -0
  19. package/spec/output-schemas/status.schema.json +27 -16
  20. package/src/cli/commands/ask.ts +31 -12
  21. package/src/cli/commands/audit.ts +23 -4
  22. package/src/cli/commands/collection/list.ts +39 -5
  23. package/src/cli/commands/embed.ts +3 -3
  24. package/src/cli/commands/links.ts +34 -131
  25. package/src/cli/commands/ls.ts +6 -1
  26. package/src/cli/commands/shared.ts +7 -0
  27. package/src/cli/commands/status.ts +5 -0
  28. package/src/cli/program.ts +12 -2
  29. package/src/config/loader.ts +43 -0
  30. package/src/config/types.ts +8 -0
  31. package/src/core/audit-contract.ts +16 -4
  32. package/src/core/audit-freshness.ts +11 -1
  33. package/src/core/audit-links.ts +145 -25
  34. package/src/core/audit-provenance.ts +11 -4
  35. package/src/core/audit-workspace.ts +19 -4
  36. package/src/core/audit.ts +67 -15
  37. package/src/core/context-compiler.ts +3 -0
  38. package/src/core/context-evidence.ts +11 -0
  39. package/src/core/graph-edge-confidence.ts +23 -1
  40. package/src/core/host-paths.ts +24 -5
  41. package/src/core/knowledge-impact.ts +28 -0
  42. package/src/core/link-workspace.ts +324 -0
  43. package/src/core/request-receipts.ts +63 -9
  44. package/src/core/retrieval-replay-candidate.ts +6 -0
  45. package/src/core/retrieval-trace-request.ts +3 -0
  46. package/src/core/windows-private-path.ts +136 -1
  47. package/src/embed/backlog.ts +22 -10
  48. package/src/embed/variant-backlog.ts +34 -9
  49. package/src/index.ts +14 -1
  50. package/src/ingestion/graph-reconciliation.ts +77 -15
  51. package/src/ingestion/source-availability/darwin-path.ts +9 -3
  52. package/src/ingestion/sync.ts +22 -1
  53. package/src/ingestion/types.ts +14 -0
  54. package/src/llm/inference-scope.ts +19 -0
  55. package/src/mcp/http-egress.ts +42 -3
  56. package/src/mcp/tools/audit.ts +11 -2
  57. package/src/mcp/tools/changes.ts +1 -0
  58. package/src/mcp/tools/links.ts +3 -0
  59. package/src/mcp/tools/sessions.ts +33 -4
  60. package/src/mcp/tools/status.ts +22 -6
  61. package/src/pipeline/expansion.ts +19 -31
  62. package/src/pipeline/graph-retrieval.ts +22 -2
  63. package/src/pipeline/hybrid.ts +1 -1
  64. package/src/pipeline/types.ts +6 -3
  65. package/src/serve/embed-scheduler.ts +2 -2
  66. package/src/serve/findings-pass.ts +1 -1
  67. package/src/serve/host-path-redaction.ts +51 -14
  68. package/src/serve/public/components/BootstrapStatus.tsx +5 -3
  69. package/src/serve/public/components/CaptureModal.tsx +1 -1
  70. package/src/serve/public/components/CollectionModelDialog.tsx +16 -13
  71. package/src/serve/public/components/CollectionsEmptyState.tsx +5 -3
  72. package/src/serve/public/components/FirstRunWizard.tsx +4 -2
  73. package/src/serve/public/components/sessions/SourcesPanel.tsx +77 -60
  74. package/src/serve/public/pages/Collections.tsx +16 -13
  75. package/src/serve/public/pages/Connectors.tsx +7 -4
  76. package/src/serve/public/pages/Dashboard.tsx +8 -6
  77. package/src/serve/public/pages/GraphView.tsx +2 -0
  78. package/src/serve/resident-runtime.ts +7 -0
  79. package/src/serve/routes/changes.ts +6 -1
  80. package/src/serve/routes/links.ts +13 -0
  81. package/src/serve/routes/sessions.ts +81 -37
  82. package/src/serve/server.ts +5 -3
  83. package/src/serve/status-model.ts +10 -6
  84. package/src/serve/status.ts +2 -7
  85. package/src/sessions/config-refresh.ts +111 -0
  86. package/src/store/migrations/033-drop-documents-active-index.ts +30 -0
  87. package/src/store/migrations/034-collection-link-workspace.ts +47 -0
  88. package/src/store/migrations/index.ts +4 -0
  89. package/src/store/sqlite/adapter.ts +392 -233
  90. package/src/store/sqlite/change-journal-store.ts +1 -1
  91. package/src/store/sqlite/eligibility.ts +8 -2
  92. package/src/store/sqlite/graph-link-resolver.ts +252 -5
  93. package/src/store/sqlite/graph-neighbors.ts +147 -40
  94. package/src/store/sqlite/graph-reference-state.ts +13 -2
  95. package/src/store/sqlite/legacy-vector-ownership.ts +2 -1
  96. package/src/store/sqlite/workspace-link-resolver.ts +654 -0
  97. package/src/store/types.ts +49 -3
  98. package/src/store/vector/stats.ts +1 -1
  99. package/browser-extension/artifacts/gno-browser-clipper-v2.7.0.zip.sha256 +0 -1
@@ -2,6 +2,11 @@
2
2
  * Seed-scoped one-hop graph neighbor resolution for query-time expansion.
3
3
  * Avoids collection-wide getGraph correlated link resolution.
4
4
  *
5
+ * Resolution and scope are separate steps: links resolve with the shared
6
+ * workspace-aware resolver, then the collection allowlist is applied to the
7
+ * resolved source AND target of every edge before scoring or limits, so a
8
+ * cross-collection edge never widens the caller's scope.
9
+ *
5
10
  * @module src/store/sqlite/graph-neighbors
6
11
  */
7
12
 
@@ -21,7 +26,16 @@ import {
21
26
  mergeGraphEdgeAudit,
22
27
  } from "../../core/graph-edge-confidence";
23
28
  import { normalizeWikiName, stripWikiMdExt } from "../../core/links";
24
- import { resolveGraphLinkTargets } from "./graph-link-resolver";
29
+ import {
30
+ isTraversableResolution,
31
+ linkSourceIdentity,
32
+ resolveGraphLinkTargets,
33
+ } from "./graph-link-resolver";
34
+ import {
35
+ loadLinkWorkspaceMemberships,
36
+ workspaceKeyForDocument,
37
+ workspaceMemberCollections,
38
+ } from "./workspace-link-resolver";
25
39
 
26
40
  const MAX_SEED_DOCUMENTS = 5;
27
41
  const DEFAULT_EDGE_LIMIT = 10_000;
@@ -40,17 +54,23 @@ interface ResolvedEdgeRow {
40
54
  link_type: "wiki" | "markdown";
41
55
  match_rank: number | null;
42
56
  match_count: number | null;
57
+ reason?: string;
43
58
  }
44
59
 
45
60
  interface RawLinkRow {
46
61
  id: number;
47
62
  source_docid: string;
48
63
  source_collection: string;
64
+ source_rel_path: string;
49
65
  target_ref_norm: string;
50
66
  target_collection: string | null;
51
67
  link_type: "wiki" | "markdown";
52
68
  }
53
69
 
70
+ const RAW_LINK_COLUMNS = `dl.id, src.docid AS source_docid,
71
+ src.collection AS source_collection, src.rel_path AS source_rel_path,
72
+ dl.target_ref_norm, dl.target_collection, dl.link_type`;
73
+
54
74
  const addWikiKeyVariants = (keySet: Set<string>, value: string): void => {
55
75
  if (!value) {
56
76
  return;
@@ -83,6 +103,33 @@ const matchesWikiKey = (targetRefNorm: string, keys: Set<string>): boolean => {
83
103
  return false;
84
104
  };
85
105
 
106
+ const escapeLike = (value: string): string =>
107
+ value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
108
+
109
+ /** Effective graph allowlist; undefined means unrestricted. */
110
+ export const graphCollectionAllowlist = (options: {
111
+ collection?: string;
112
+ collections?: string[];
113
+ }): Set<string> | undefined => {
114
+ if (options.collections) return new Set(options.collections);
115
+ if (options.collection) return new Set([options.collection]);
116
+ return undefined;
117
+ };
118
+
119
+ const inScopeClause = (
120
+ allowlist: Set<string> | undefined,
121
+ column: string
122
+ ): { sql: string; params: string[] } =>
123
+ allowlist
124
+ ? {
125
+ sql:
126
+ allowlist.size === 0
127
+ ? "AND 0"
128
+ : `AND ${column} IN (${[...allowlist].map(() => "?").join(",")})`,
129
+ params: [...allowlist],
130
+ }
131
+ : { sql: "", params: [] };
132
+
86
133
  const loadSeeds = (db: Database, seedDocumentIds: number[]): SeedDocRow[] => {
87
134
  const uniqueIds = [...new Set(seedDocumentIds)]
88
135
  .filter((id) => Number.isInteger(id) && id > 0)
@@ -104,11 +151,12 @@ const loadSeeds = (db: Database, seedDocumentIds: number[]): SeedDocRow[] => {
104
151
  const collectIncomingCandidateLinks = (
105
152
  db: Database,
106
153
  seeds: SeedDocRow[],
107
- collection: string | undefined
154
+ allowlist: Set<string> | undefined
108
155
  ): { links: RawLinkRow[]; examinedLinkRows: number } => {
109
156
  const linksById = new Map<number, RawLinkRow>();
110
157
  let examinedLinkRows = 0;
111
158
  const seedIdSet = new Set(seeds.map((seed) => seed.id));
159
+ const sourceScope = inScopeClause(allowlist, "src.collection");
112
160
 
113
161
  const wikiKeysByCollection = new Map<string, Set<string>>();
114
162
  for (const seed of seeds) {
@@ -124,9 +172,7 @@ const collectIncomingCandidateLinks = (
124
172
  const collectionPlaceholders = targetCollections.map(() => "?").join(",");
125
173
  const wikiRows = db
126
174
  .query<RawLinkRow & { source_doc_id: number }, string[]>(
127
- `SELECT dl.id, dl.source_doc_id, src.docid AS source_docid,
128
- src.collection AS source_collection, dl.target_ref_norm,
129
- dl.target_collection, dl.link_type
175
+ `SELECT dl.source_doc_id, ${RAW_LINK_COLUMNS}
130
176
  FROM doc_links dl
131
177
  JOIN documents src ON src.id = dl.source_doc_id AND src.active = 1
132
178
  WHERE dl.link_type = 'wiki'
@@ -135,13 +181,9 @@ const collectIncomingCandidateLinks = (
135
181
  AND src.collection IN (${collectionPlaceholders}))
136
182
  OR dl.target_collection IN (${collectionPlaceholders})
137
183
  )
138
- ${collection ? "AND src.collection = ?" : ""}`
184
+ ${sourceScope.sql}`
139
185
  )
140
- .all(
141
- ...targetCollections,
142
- ...targetCollections,
143
- ...(collection ? [collection] : [])
144
- );
186
+ .all(...targetCollections, ...targetCollections, ...sourceScope.params);
145
187
  examinedLinkRows += wikiRows.length;
146
188
 
147
189
  for (const row of wikiRows) {
@@ -157,12 +199,69 @@ const collectIncomingCandidateLinks = (
157
199
  }
158
200
  }
159
201
 
202
+ // Workspace seeds: plain links from any member collection whose last path
203
+ // segment names the seed file. The resolver decides which really land on
204
+ // the seed; this only bounds the candidate rows.
205
+ const memberships = loadLinkWorkspaceMemberships(db);
206
+ const workspaceSeeds = seeds
207
+ .map((seed) => ({
208
+ seed,
209
+ wsKey: workspaceKeyForDocument(
210
+ memberships,
211
+ seed.collection,
212
+ seed.rel_path
213
+ ),
214
+ }))
215
+ .filter(
216
+ (entry): entry is { seed: SeedDocRow; wsKey: string } =>
217
+ entry.wsKey !== null
218
+ );
219
+ if (workspaceSeeds.length > 0) {
220
+ const members = workspaceMemberCollections(
221
+ memberships,
222
+ new Set(workspaceSeeds.map(({ wsKey }) => wsKey))
223
+ );
224
+ const conditions: string[] = [];
225
+ const params: string[] = [];
226
+ for (const { seed } of workspaceSeeds) {
227
+ const base = stripWikiMdExt(
228
+ normalizeWikiName(seed.rel_path.split("/").pop() ?? seed.rel_path)
229
+ );
230
+ for (const value of [base, `${base}.md`]) {
231
+ conditions.push(
232
+ "dl.target_ref_norm = ? OR dl.target_ref_norm LIKE ? ESCAPE '\\'"
233
+ );
234
+ params.push(value, `%/${escapeLike(value)}`);
235
+ }
236
+ for (const key of wikiKeysForSeed(seed)) {
237
+ conditions.push("dl.target_ref_norm = ?");
238
+ params.push(key);
239
+ }
240
+ }
241
+ if (members.length > 0 && conditions.length > 0) {
242
+ const rows = db
243
+ .query<RawLinkRow & { source_doc_id: number }, string[]>(
244
+ `SELECT dl.source_doc_id, ${RAW_LINK_COLUMNS}
245
+ FROM doc_links dl
246
+ JOIN documents src ON src.id = dl.source_doc_id AND src.active = 1
247
+ WHERE dl.link_type = 'wiki'
248
+ AND dl.target_collection IS NULL
249
+ AND src.collection IN (${members.map(() => "?").join(",")})
250
+ AND (${conditions.join(" OR ")})
251
+ ${sourceScope.sql}`
252
+ )
253
+ .all(...members, ...params, ...sourceScope.params);
254
+ examinedLinkRows += rows.length;
255
+ for (const row of rows) {
256
+ if (!seedIdSet.has(row.source_doc_id)) linksById.set(row.id, row);
257
+ }
258
+ }
259
+ }
260
+
160
261
  for (const seed of seeds) {
161
262
  const mdRows = db
162
263
  .query<RawLinkRow & { source_doc_id: number }, string[]>(
163
- `SELECT dl.id, dl.source_doc_id, src.docid AS source_docid,
164
- src.collection AS source_collection, dl.target_ref_norm,
165
- dl.target_collection, dl.link_type
264
+ `SELECT dl.source_doc_id, ${RAW_LINK_COLUMNS}
166
265
  FROM doc_links dl
167
266
  JOIN documents src ON src.id = dl.source_doc_id AND src.active = 1
168
267
  WHERE dl.link_type = 'markdown'
@@ -171,13 +270,13 @@ const collectIncomingCandidateLinks = (
171
270
  (dl.target_collection IS NULL AND src.collection = ?)
172
271
  OR dl.target_collection = ?
173
272
  )
174
- ${collection ? "AND src.collection = ?" : ""}`
273
+ ${sourceScope.sql}`
175
274
  )
176
275
  .all(
177
276
  seed.rel_path,
178
277
  seed.collection,
179
278
  seed.collection,
180
- ...(collection ? [collection] : [])
279
+ ...sourceScope.params
181
280
  );
182
281
  examinedLinkRows += mdRows.length;
183
282
  for (const row of mdRows) {
@@ -193,29 +292,24 @@ const collectIncomingCandidateLinks = (
193
292
  const loadRawLinksForSources = (
194
293
  db: Database,
195
294
  sourceIds: number[],
196
- collection: string | undefined
295
+ allowlist: Set<string> | undefined
197
296
  ): RawLinkRow[] => {
198
297
  if (sourceIds.length === 0) {
199
298
  return [];
200
299
  }
201
300
  const sourcePlaceholders = sourceIds.map(() => "?").join(",");
202
- const params: (string | number)[] = [...sourceIds];
203
- if (collection) {
204
- params.push(collection);
205
- }
301
+ const sourceScope = inScopeClause(allowlist, "src.collection");
206
302
  return db
207
303
  .query<RawLinkRow, (string | number)[]>(
208
- `SELECT dl.id, src.docid AS source_docid,
209
- src.collection AS source_collection,
210
- dl.target_ref_norm, dl.target_collection, dl.link_type
304
+ `SELECT ${RAW_LINK_COLUMNS}
211
305
  FROM documents src
212
306
  JOIN doc_links dl ON dl.source_doc_id = src.id
213
307
  WHERE src.active = 1
214
308
  AND src.id IN (${sourcePlaceholders})
215
- ${collection ? "AND src.collection = ?" : ""}
309
+ ${sourceScope.sql}
216
310
  ORDER BY src.id ASC, dl.id ASC`
217
311
  )
218
- .all(...params);
312
+ .all(...sourceIds, ...sourceScope.params);
219
313
  };
220
314
 
221
315
  const resolveRawEdges = (
@@ -223,36 +317,45 @@ const resolveRawEdges = (
223
317
  rawRows: RawLinkRow[],
224
318
  incomingLinkIds: Set<number>,
225
319
  seedIds: Set<number>,
226
- collection: string | undefined
320
+ allowlist: Set<string> | undefined
227
321
  ): ResolvedEdgeRow[] => {
228
- const inScopeRows = rawRows.filter(
229
- (row) =>
230
- !collection ||
231
- (row.target_collection ?? row.source_collection) === collection
232
- );
233
322
  const resolvedTargets = resolveGraphLinkTargets(
234
323
  db,
235
- inScopeRows.map((row) => ({
324
+ rawRows.map((row) => ({
236
325
  targetRefNorm: row.target_ref_norm,
237
326
  targetCollection: row.target_collection ?? row.source_collection,
238
327
  linkType: row.link_type,
328
+ source: linkSourceIdentity(row),
239
329
  }))
240
330
  );
241
331
  const rows: ResolvedEdgeRow[] = [];
242
- for (const [index, rawRow] of inScopeRows.entries()) {
332
+ for (const [index, rawRow] of rawRows.entries()) {
243
333
  const target = resolvedTargets[index];
244
334
  if (
245
- !target ||
335
+ !isTraversableResolution(target) ||
246
336
  (incomingLinkIds.has(rawRow.id) && !seedIds.has(target.targetId))
247
337
  ) {
248
338
  continue;
249
339
  }
340
+ // Scope applies to the resolved identities, never the declared prefix.
341
+ if (
342
+ allowlist &&
343
+ (!allowlist.has(rawRow.source_collection) ||
344
+ !allowlist.has(
345
+ target.targetCollection ??
346
+ rawRow.target_collection ??
347
+ rawRow.source_collection
348
+ ))
349
+ ) {
350
+ continue;
351
+ }
250
352
  rows.push({
251
353
  source_docid: rawRow.source_docid,
252
354
  target_docid: target.targetDocid,
253
355
  link_type: rawRow.link_type,
254
356
  match_rank: target.matchRank,
255
357
  match_count: target.matchCount,
358
+ reason: target.reason,
256
359
  });
257
360
  }
258
361
  return rows;
@@ -277,7 +380,8 @@ const toGraphLinks = (
277
380
  const { confidence, audit } = classifyResolvedGraphEdge(
278
381
  row.link_type,
279
382
  row.match_rank,
280
- row.match_count
383
+ row.match_count,
384
+ row.reason
281
385
  );
282
386
  const existing = edgeMap.get(key);
283
387
  if (existing) {
@@ -326,7 +430,10 @@ export function queryGraphNeighborsForSeeds(
326
430
  1,
327
431
  Math.min(50_000, options.limitEdges ?? DEFAULT_EDGE_LIMIT)
328
432
  );
329
- const seeds = loadSeeds(db, options.seedDocumentIds);
433
+ const allowlist = graphCollectionAllowlist(options);
434
+ const seeds = loadSeeds(db, options.seedDocumentIds).filter(
435
+ (seed) => !allowlist || allowlist.has(seed.collection)
436
+ );
330
437
  if (seeds.length === 0) {
331
438
  return {
332
439
  links: [],
@@ -339,9 +446,9 @@ export function queryGraphNeighborsForSeeds(
339
446
  }
340
447
 
341
448
  const seedIds = seeds.map((seed) => seed.id);
342
- const outgoingLinks = loadRawLinksForSources(db, seedIds, options.collection);
449
+ const outgoingLinks = loadRawLinksForSources(db, seedIds, allowlist);
343
450
  const { links: incomingLinks, examinedLinkRows: incomingCandidates } =
344
- collectIncomingCandidateLinks(db, seeds, options.collection);
451
+ collectIncomingCandidateLinks(db, seeds, allowlist);
345
452
  const incomingLinkIds = new Set(incomingLinks.map((link) => link.id));
346
453
  const rawLinksById = new Map(
347
454
  [...outgoingLinks, ...incomingLinks].map((link) => [link.id, link])
@@ -351,7 +458,7 @@ export function queryGraphNeighborsForSeeds(
351
458
  [...rawLinksById.values()],
352
459
  incomingLinkIds,
353
460
  new Set(seedIds),
354
- options.collection
461
+ allowlist
355
462
  );
356
463
 
357
464
  const examinedLinkRows = outgoingLinks.length + incomingCandidates;
@@ -8,7 +8,7 @@ import type {
8
8
  } from "../types";
9
9
 
10
10
  import { buildWikiMatchExpression } from "../../core/graph-resolver";
11
- import { normalizeWikiName } from "../../core/links";
11
+ import { normalizeWikiName, stripWikiMdExt } from "../../core/links";
12
12
 
13
13
  const SNAPSHOT_COLUMNS = `document_id AS documentId, collection, rel_path AS relPath,
14
14
  docid, uri, title, mirror_hash AS mirrorHash, source_hash AS sourceHash,
@@ -101,6 +101,11 @@ export function createGraphReferenceStore(db: Database): GraphReferenceStore {
101
101
  ),
102
102
  wiki_rel: normalizeWikiName(d.relPath),
103
103
  wiki_stem: normalizeWikiName(d.relPath.replace(/\.[^/.]+$/, "")),
104
+ // Workspace links name a file by its last path segment from any
105
+ // collection; every such referrer may re-resolve after this change.
106
+ wiki_base: stripWikiMdExt(
107
+ normalizeWikiName(d.relPath.split("/").pop() ?? d.relPath)
108
+ ),
104
109
  }));
105
110
  return db
106
111
  .query<{ sourceId: number }, [string]>(`
@@ -110,7 +115,8 @@ export function createGraphReferenceStore(db: Database): GraphReferenceStore {
110
115
  json_extract(value, '$.title') AS title,
111
116
  json_extract(value, '$.wiki_title') AS wiki_title,
112
117
  json_extract(value, '$.wiki_rel') AS wiki_rel,
113
- json_extract(value, '$.wiki_stem') AS wiki_stem
118
+ json_extract(value, '$.wiki_stem') AS wiki_stem,
119
+ json_extract(value, '$.wiki_base') AS wiki_base
114
120
  FROM json_each(?)
115
121
  )
116
122
  SELECT DISTINCT dl.source_doc_id AS sourceId
@@ -121,6 +127,11 @@ export function createGraphReferenceStore(db: Database): GraphReferenceStore {
121
127
  (dl.link_type = 'wiki' AND ${buildWikiMatchExpression("t", "dl.target_ref_norm")})
122
128
  )) OR
123
129
  (dl.link_type = 'wiki' AND dl.target_ref_norm IN (t.wiki_title, t.wiki_rel, t.wiki_stem))
130
+ OR (dl.link_type = 'wiki' AND dl.target_collection IS NULL AND (
131
+ dl.target_ref_norm IN (t.wiki_base, t.wiki_base || '.md')
132
+ OR substr(dl.target_ref_norm, -length(t.wiki_base) - 1) = '/' || t.wiki_base
133
+ OR substr(dl.target_ref_norm, -length(t.wiki_base) - 4) = '/' || t.wiki_base || '.md'
134
+ ))
124
135
  OR (dl.link_type = 'markdown' AND t.collection = src.collection AND dl.target_ref_norm = t.rel_path)
125
136
  )
126
137
  ORDER BY dl.source_doc_id
@@ -9,7 +9,8 @@ export type LegacyTitleSnapshot = Map<string, (string | null)[]>;
9
9
  function activeTitle(db: Database, mirror: string): TitleRow | null {
10
10
  return db
11
11
  .query<TitleRow, [string]>(`
12
- SELECT title FROM documents WHERE mirror_hash = ? AND active = 1
12
+ SELECT title FROM documents INDEXED BY idx_documents_mirror_hash
13
+ WHERE mirror_hash = ? AND active = 1
13
14
  ORDER BY id LIMIT 1
14
15
  `)
15
16
  .get(mirror);