@gmickel/gno 1.33.0 → 1.34.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.
@@ -0,0 +1,191 @@
1
+ /** Linear-time bulk target resolution for large graph-link inventories. */
2
+
3
+ import type { Database } from "bun:sqlite";
4
+
5
+ import type {
6
+ GraphLinkTarget,
7
+ ResolvedGraphLinkTarget,
8
+ } from "./graph-link-resolver";
9
+
10
+ import { stripWikiMdExt } from "../../core/links";
11
+
12
+ interface IndexedDocument {
13
+ id: number;
14
+ docid: string;
15
+ collection: string;
16
+ titleNorm: string | null;
17
+ relNorm: string;
18
+ relRaw: string;
19
+ }
20
+
21
+ type Lookup = Map<string, IndexedDocument[]>;
22
+
23
+ export const GRAPH_LINK_BULK_MAX_DOCUMENTS = 100_000;
24
+
25
+ const lookupKey = (collection: string, value: string): string =>
26
+ `${collection}\0${value}`;
27
+
28
+ const suffixes = (value: string): string[] => {
29
+ const output = [value];
30
+ let separator = value.indexOf("/");
31
+ while (separator >= 0) {
32
+ const suffix = value.slice(separator + 1);
33
+ if (suffix) output.push(suffix);
34
+ separator = value.indexOf("/", separator + 1);
35
+ }
36
+ return output;
37
+ };
38
+
39
+ const appendLookup = (
40
+ lookup: Lookup,
41
+ collection: string,
42
+ value: string,
43
+ document: IndexedDocument
44
+ ): void => {
45
+ const key = lookupKey(collection, value);
46
+ const documents = lookup.get(key) ?? [];
47
+ documents.push(document);
48
+ lookup.set(key, documents);
49
+ };
50
+
51
+ const candidatesFor = (
52
+ lookup: Lookup,
53
+ collection: string,
54
+ values: readonly string[]
55
+ ): IndexedDocument[] => {
56
+ const byId = new Map<number, IndexedDocument>();
57
+ for (const value of values) {
58
+ for (const document of lookup.get(lookupKey(collection, value)) ?? []) {
59
+ byId.set(document.id, document);
60
+ }
61
+ }
62
+ return [...byId.values()].sort((left, right) => left.id - right.id);
63
+ };
64
+
65
+ const resolved = (
66
+ documents: readonly IndexedDocument[],
67
+ matchRank: number
68
+ ): ResolvedGraphLinkTarget | null => {
69
+ const first = documents[0];
70
+ if (!first) return null;
71
+ return {
72
+ targetId: first.id,
73
+ targetDocid: first.docid,
74
+ matchRank,
75
+ matchCount: documents.length,
76
+ };
77
+ };
78
+
79
+ /**
80
+ * Resolve a large target set with lookup tables equivalent to the ranked SQL
81
+ * resolver. SQLite still performs lower()/trim(), preserving its normalization
82
+ * semantics; resolution then scales with documents plus path segments.
83
+ */
84
+ export const resolveGraphLinkTargetsBulk = (
85
+ db: Database,
86
+ targets: readonly GraphLinkTarget[],
87
+ maxDocuments = GRAPH_LINK_BULK_MAX_DOCUMENTS
88
+ ): Array<ResolvedGraphLinkTarget | null> | null => {
89
+ const boundedMaxDocuments = Math.max(
90
+ 1,
91
+ Math.min(GRAPH_LINK_BULK_MAX_DOCUMENTS, maxDocuments)
92
+ );
93
+ const rows = db
94
+ .query<
95
+ {
96
+ id: number;
97
+ docid: string;
98
+ collection: string;
99
+ title_norm: string | null;
100
+ rel_norm: string;
101
+ rel_raw: string;
102
+ },
103
+ [number]
104
+ >(
105
+ `SELECT id, docid, collection, lower(trim(title)) AS title_norm,
106
+ lower(rel_path) AS rel_norm, rel_path AS rel_raw
107
+ FROM documents
108
+ WHERE active = 1
109
+ ORDER BY id
110
+ LIMIT ?`
111
+ )
112
+ .all(boundedMaxDocuments + 1);
113
+ // The fast lookup-table path is intentionally bounded. Callers fall back to
114
+ // set-oriented SQL batches when the active index exceeds this memory cap.
115
+ if (rows.length > boundedMaxDocuments) return null;
116
+ const titleExact: Lookup = new Map();
117
+ const relExact: Lookup = new Map();
118
+ const relExactRaw: Lookup = new Map();
119
+ const relSuffix: Lookup = new Map();
120
+ for (const row of rows) {
121
+ const document: IndexedDocument = {
122
+ id: row.id,
123
+ docid: row.docid,
124
+ collection: row.collection,
125
+ titleNorm: row.title_norm,
126
+ relNorm: row.rel_norm,
127
+ relRaw: row.rel_raw,
128
+ };
129
+ if (document.titleNorm !== null) {
130
+ appendLookup(titleExact, row.collection, document.titleNorm, document);
131
+ }
132
+ appendLookup(relExact, row.collection, document.relNorm, document);
133
+ appendLookup(relExactRaw, row.collection, document.relRaw, document);
134
+ for (const suffix of suffixes(document.relNorm)) {
135
+ appendLookup(relSuffix, row.collection, suffix, document);
136
+ }
137
+ }
138
+
139
+ const cache = new Map<string, ResolvedGraphLinkTarget | null>();
140
+ return targets.map((target) => {
141
+ const cacheKey = JSON.stringify([
142
+ target.linkType,
143
+ target.targetCollection,
144
+ target.targetRefNorm,
145
+ ]);
146
+ if (cache.has(cacheKey)) return cache.get(cacheKey) ?? null;
147
+ let result: ResolvedGraphLinkTarget | null;
148
+ if (target.linkType === "markdown") {
149
+ result = resolved(
150
+ candidatesFor(relExactRaw, target.targetCollection, [
151
+ target.targetRefNorm,
152
+ ]),
153
+ 5
154
+ );
155
+ } else {
156
+ const baseRef = stripWikiMdExt(target.targetRefNorm);
157
+ const baseRefMd = `${baseRef}.md`;
158
+ const rankedCandidates: Array<[number, Lookup, string[]]> = [
159
+ [1, titleExact, [baseRef]],
160
+ [2, titleExact, [baseRefMd]],
161
+ [3, titleExact, suffixes(baseRef)],
162
+ [
163
+ 4,
164
+ titleExact,
165
+ suffixes(baseRefMd)
166
+ .filter((value) => value.endsWith(".md"))
167
+ .map((value) => value.slice(0, -3)),
168
+ ],
169
+ [5, relExact, [baseRef]],
170
+ [6, relExact, [baseRefMd]],
171
+ [7, relSuffix, [baseRefMd]],
172
+ [8, relSuffix, [baseRef]],
173
+ [9, relExact, suffixes(baseRefMd)],
174
+ [10, relExact, suffixes(baseRef)],
175
+ ];
176
+ result = null;
177
+ for (const [rank, lookup, values] of rankedCandidates) {
178
+ const documents = candidatesFor(
179
+ lookup,
180
+ target.targetCollection,
181
+ values
182
+ );
183
+ if (documents.length === 0) continue;
184
+ result = resolved(documents, rank);
185
+ break;
186
+ }
187
+ }
188
+ cache.set(cacheKey, result);
189
+ return result;
190
+ });
191
+ };
@@ -7,6 +7,7 @@
7
7
  import type { Database } from "bun:sqlite";
8
8
 
9
9
  import { stripWikiMdExt } from "../../core/links";
10
+ import { resolveGraphLinkTargetsBulk } from "./graph-link-bulk-resolver";
10
11
 
11
12
  export interface GraphLinkTarget {
12
13
  targetRefNorm: string;
@@ -22,6 +23,60 @@ export interface ResolvedGraphLinkTarget {
22
23
  }
23
24
 
24
25
  const MAX_SQL_PARAMS = 900;
26
+ const BULK_RESOLUTION_THRESHOLD = 128;
27
+ export const AUDIT_LINK_SNAPSHOT_MAX_DOCUMENTS = 50_000;
28
+ export const AUDIT_LINK_SNAPSHOT_MAX_LINKS = 50_000;
29
+
30
+ export interface AuditLinkSnapshotDocument {
31
+ id: number;
32
+ docid: string;
33
+ uri: string;
34
+ collection: string;
35
+ relPath: string;
36
+ recordSourcePath: string | null;
37
+ title: string | null;
38
+ mirrorHash: string | null;
39
+ }
40
+
41
+ export interface AuditLinkSnapshotLink {
42
+ sourceId: number;
43
+ sourceDocid: string;
44
+ sourceUri: string;
45
+ sourceCollection: string;
46
+ sourceRelPath: string;
47
+ targetRef: string;
48
+ targetRefNorm: string;
49
+ targetAnchor: string | null;
50
+ targetCollection: string;
51
+ linkType: "wiki" | "markdown";
52
+ startLine: number;
53
+ startCol: number;
54
+ endLine: number;
55
+ endCol: number;
56
+ resolved: ResolvedGraphLinkTarget | null;
57
+ }
58
+
59
+ export interface AuditLinkSnapshot {
60
+ documents: AuditLinkSnapshotDocument[];
61
+ /** Optional finding scope when documents also include graph-wide evidence. */
62
+ auditedDocumentIds?: number[];
63
+ links: AuditLinkSnapshotLink[];
64
+ totals: { documents: number; links: number };
65
+ truncated: { documents: boolean; links: boolean };
66
+ metrics: {
67
+ documentRowsExamined: number;
68
+ linkRowsExamined: number;
69
+ uniqueTargetsResolved: number;
70
+ batchedResolution: true;
71
+ };
72
+ }
73
+
74
+ export interface AuditLinkSnapshotOptions {
75
+ collections?: readonly string[];
76
+ pathPrefixes?: readonly string[];
77
+ maxDocuments?: number;
78
+ maxLinks?: number;
79
+ }
25
80
 
26
81
  const chunkArray = <T>(items: T[], chunkSize: number): T[][] => {
27
82
  const chunks: T[][] = [];
@@ -36,7 +91,7 @@ const suffixMatch = (targetExpr: string, valueExpr: string): string =>
36
91
  AND (length(${targetExpr}) = length(${valueExpr})
37
92
  OR substr(${targetExpr}, -length(${valueExpr}) - 1, 1) = '/'))`;
38
93
 
39
- const resolveUniqueGraphLinkTargets = (
94
+ export const resolveGraphLinkTargetsSql = (
40
95
  db: Database,
41
96
  targets: GraphLinkTarget[]
42
97
  ): Array<ResolvedGraphLinkTarget | null> => {
@@ -234,8 +289,192 @@ export function resolveGraphLinkTargets(
234
289
  originalToUniqueIndex.push(uniqueIndex);
235
290
  }
236
291
 
237
- const uniqueResults = resolveUniqueGraphLinkTargets(db, uniqueTargets);
292
+ if (uniqueTargets.length > BULK_RESOLUTION_THRESHOLD) {
293
+ const bulkResults = resolveGraphLinkTargetsBulk(db, uniqueTargets);
294
+ if (bulkResults) {
295
+ return originalToUniqueIndex.map(
296
+ (uniqueIndex) => bulkResults[uniqueIndex] ?? null
297
+ );
298
+ }
299
+ }
300
+
301
+ const uniqueResults = resolveGraphLinkTargetsSql(db, uniqueTargets);
238
302
  return originalToUniqueIndex.map(
239
303
  (uniqueIndex) => uniqueResults[uniqueIndex] ?? null
240
304
  );
241
305
  }
306
+
307
+ /**
308
+ * Capture one bounded, read-only link inventory using set-oriented SQL and the
309
+ * same target resolver as graph expansion. No query is issued per finding.
310
+ */
311
+ export function captureAuditLinkSnapshot(
312
+ db: Database,
313
+ options: AuditLinkSnapshotOptions = {}
314
+ ): AuditLinkSnapshot {
315
+ const maxDocuments = Math.max(
316
+ 1,
317
+ Math.min(
318
+ AUDIT_LINK_SNAPSHOT_MAX_DOCUMENTS,
319
+ options.maxDocuments ?? AUDIT_LINK_SNAPSHOT_MAX_DOCUMENTS
320
+ )
321
+ );
322
+ const maxLinks = Math.max(
323
+ 1,
324
+ Math.min(
325
+ AUDIT_LINK_SNAPSHOT_MAX_LINKS,
326
+ options.maxLinks ?? AUDIT_LINK_SNAPSHOT_MAX_LINKS
327
+ )
328
+ );
329
+ const conditions = ["d.active = 1"];
330
+ const params: string[] = [];
331
+ const collections = [...new Set(options.collections ?? [])]
332
+ .map((value) => value.normalize("NFC").trim())
333
+ .filter(Boolean)
334
+ .sort();
335
+ if (collections.length > 0) {
336
+ conditions.push(
337
+ `d.collection IN (${collections.map(() => "?").join(",")})`
338
+ );
339
+ params.push(...collections);
340
+ }
341
+ const prefixes = [...new Set(options.pathPrefixes ?? [])]
342
+ .map((value) => value.normalize("NFC").trim().replace(/^\/+/, ""))
343
+ .filter(Boolean)
344
+ .sort();
345
+ if (prefixes.length > 0) {
346
+ conditions.push(
347
+ `(${prefixes.map(() => "COALESCE(NULLIF(d.record_source_path, ''), d.rel_path) = ? OR COALESCE(NULLIF(d.record_source_path, ''), d.rel_path) LIKE ? ESCAPE '\\'").join(" OR ")})`
348
+ );
349
+ for (const prefix of prefixes) {
350
+ const escaped = prefix
351
+ .replaceAll("\\", "\\\\")
352
+ .replaceAll("%", "\\%")
353
+ .replaceAll("_", "\\_");
354
+ params.push(prefix, `${escaped}/%`);
355
+ }
356
+ }
357
+ const where = conditions.join(" AND ");
358
+ const totalDocuments =
359
+ db
360
+ .query<{ count: number }, string[]>(
361
+ `SELECT COUNT(*) AS count FROM documents d WHERE ${where}`
362
+ )
363
+ .get(...params)?.count ?? 0;
364
+ const documentRows = db
365
+ .query<
366
+ {
367
+ id: number;
368
+ docid: string;
369
+ uri: string;
370
+ collection: string;
371
+ rel_path: string;
372
+ record_source_path: string | null;
373
+ title: string | null;
374
+ mirror_hash: string | null;
375
+ },
376
+ (string | number)[]
377
+ >(
378
+ `SELECT d.id, d.docid, d.uri, d.collection, d.rel_path, d.record_source_path, d.title, d.mirror_hash
379
+ FROM documents d WHERE ${where} ORDER BY d.id LIMIT ?`
380
+ )
381
+ .all(...params, maxDocuments);
382
+ const totalLinks =
383
+ db
384
+ .query<{ count: number }, string[]>(
385
+ `SELECT COUNT(*) AS count
386
+ FROM doc_links dl
387
+ JOIN documents d ON d.id = dl.source_doc_id
388
+ WHERE ${where} AND dl.source = 'parsed'`
389
+ )
390
+ .get(...params)?.count ?? 0;
391
+ const rawLinks = db
392
+ .query<
393
+ {
394
+ source_id: number;
395
+ source_docid: string;
396
+ source_uri: string;
397
+ source_collection: string;
398
+ source_rel_path: string;
399
+ target_ref: string;
400
+ target_ref_norm: string;
401
+ target_anchor: string | null;
402
+ target_collection: string | null;
403
+ link_type: "wiki" | "markdown";
404
+ start_line: number;
405
+ start_col: number;
406
+ end_line: number;
407
+ end_col: number;
408
+ },
409
+ (string | number)[]
410
+ >(
411
+ `SELECT d.id AS source_id, d.docid AS source_docid,
412
+ d.uri AS source_uri, d.collection AS source_collection,
413
+ COALESCE(NULLIF(d.record_source_path, ''), d.rel_path) AS source_rel_path, dl.target_ref,
414
+ dl.target_ref_norm, dl.target_anchor, dl.target_collection,
415
+ dl.link_type, dl.start_line, dl.start_col,
416
+ dl.end_line, dl.end_col
417
+ FROM doc_links dl
418
+ JOIN documents d ON d.id = dl.source_doc_id
419
+ WHERE ${where} AND dl.source = 'parsed'
420
+ ORDER BY d.id, dl.start_line, dl.start_col, dl.id
421
+ LIMIT ?`
422
+ )
423
+ .all(...params, maxLinks);
424
+ const targets = rawLinks.map((row) => ({
425
+ targetRefNorm: row.target_ref_norm,
426
+ targetCollection: row.target_collection ?? row.source_collection,
427
+ linkType: row.link_type,
428
+ }));
429
+ const resolutions = resolveGraphLinkTargets(db, targets);
430
+ const uniqueTargetsResolved = new Set(
431
+ targets.map((target) =>
432
+ JSON.stringify([
433
+ target.linkType,
434
+ target.targetCollection,
435
+ target.targetRefNorm,
436
+ ])
437
+ )
438
+ ).size;
439
+
440
+ return {
441
+ documents: documentRows.map((row) => ({
442
+ id: row.id,
443
+ docid: row.docid,
444
+ uri: row.uri,
445
+ collection: row.collection,
446
+ relPath: row.rel_path,
447
+ recordSourcePath: row.record_source_path,
448
+ title: row.title,
449
+ mirrorHash: row.mirror_hash,
450
+ })),
451
+ links: rawLinks.map((row, index) => ({
452
+ sourceId: row.source_id,
453
+ sourceDocid: row.source_docid,
454
+ sourceUri: row.source_uri,
455
+ sourceCollection: row.source_collection,
456
+ sourceRelPath: row.source_rel_path,
457
+ targetRef: row.target_ref,
458
+ targetRefNorm: row.target_ref_norm,
459
+ targetAnchor: row.target_anchor,
460
+ targetCollection: row.target_collection ?? row.source_collection,
461
+ linkType: row.link_type,
462
+ startLine: row.start_line,
463
+ startCol: row.start_col,
464
+ endLine: row.end_line,
465
+ endCol: row.end_col,
466
+ resolved: resolutions[index] ?? null,
467
+ })),
468
+ totals: { documents: totalDocuments, links: totalLinks },
469
+ truncated: {
470
+ documents: totalDocuments > documentRows.length,
471
+ links: totalLinks > rawLinks.length,
472
+ },
473
+ metrics: {
474
+ documentRowsExamined: documentRows.length,
475
+ linkRowsExamined: rawLinks.length,
476
+ uniqueTargetsResolved,
477
+ batchedResolution: true,
478
+ },
479
+ };
480
+ }
@@ -1 +0,0 @@
1
- c398c09680951afa0b5d436f3b92cf3dfb6e49427b9df83830ce899d3b03d7e4 gno-browser-clipper-v1.33.0.zip