@gmickel/gno 1.33.0 → 1.34.1

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.
@@ -128,11 +128,7 @@ import {
128
128
  classifyResolvedGraphEdge,
129
129
  mergeGraphEdgeAudit,
130
130
  } from "../../core/graph-edge-confidence";
131
- import {
132
- buildWikiBestMatchSubquery,
133
- buildWikiBestRankMatchCountSubquery,
134
- buildWikiBestRankSubquery,
135
- } from "../../core/graph-resolver";
131
+ import { buildWikiBestMatchSubquery } from "../../core/graph-resolver";
136
132
  import { buildContentPrefilterNeedles } from "../../core/link-relevance";
137
133
  import { normalizeWikiName, stripWikiMdExt } from "../../core/links";
138
134
  import {
@@ -179,6 +175,7 @@ import {
179
175
  getLatestFileRefactorReceiptByPlanDigest as getStoredLatestFileRefactorReceiptByPlanDigest,
180
176
  } from "./file-refactor-journal-store";
181
177
  import { loadFts5Snowball } from "./fts5-snowball";
178
+ import { resolveGraphLinkTargets } from "./graph-link-resolver";
182
179
  import { queryGraphNeighborsForSeeds } from "./graph-neighbors";
183
180
  import {
184
181
  appendExportManifest as appendStoredTraceExportManifest,
@@ -515,6 +512,28 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
515
512
  }
516
513
  }
517
514
 
515
+ /** Open an existing index with SQLite enforced query-only semantics. */
516
+ openReadOnly(dbPath: string): StoreResult<void> {
517
+ try {
518
+ this.db = new Database(dbPath, { readonly: true, strict: true });
519
+ this.dbPath = dbPath;
520
+ this.db.exec("PRAGMA query_only = ON");
521
+ this.db.exec("PRAGMA busy_timeout = 5000");
522
+ this.contextGeneration += 1;
523
+ return ok(undefined);
524
+ } catch (cause) {
525
+ this.db?.close();
526
+ this.db = null;
527
+ return err(
528
+ "CONNECTION_FAILED",
529
+ cause instanceof Error
530
+ ? cause.message
531
+ : "Failed to open database read-only",
532
+ cause
533
+ );
534
+ }
535
+ }
536
+
518
537
  async close(): Promise<void> {
519
538
  if (this.db) {
520
539
  this.db.close();
@@ -1559,6 +1578,66 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1559
1578
  }
1560
1579
  }
1561
1580
 
1581
+ async listDocumentsForAudit(options: {
1582
+ collections: readonly string[];
1583
+ pathPrefixes: readonly string[];
1584
+ tags: readonly string[];
1585
+ limit: number;
1586
+ }): Promise<StoreResult<{ documents: DocumentRow[]; total: number }>> {
1587
+ try {
1588
+ const db = this.ensureOpen();
1589
+ const conditions = ["d.active = 1"];
1590
+ const params: (string | number)[] = [];
1591
+ if (options.collections.length > 0) {
1592
+ conditions.push("d.collection IN (SELECT value FROM json_each(?))");
1593
+ params.push(JSON.stringify(options.collections));
1594
+ }
1595
+ if (options.pathPrefixes.length > 0) {
1596
+ conditions.push(`EXISTS (
1597
+ SELECT 1 FROM json_each(?) prefix
1598
+ WHERE COALESCE(NULLIF(d.record_source_path, ''), d.rel_path) = prefix.value
1599
+ OR (substr(COALESCE(NULLIF(d.record_source_path, ''), d.rel_path), 1, length(prefix.value)) = prefix.value
1600
+ AND substr(COALESCE(NULLIF(d.record_source_path, ''), d.rel_path), length(prefix.value) + 1, 1) = '/')
1601
+ )`);
1602
+ params.push(JSON.stringify(options.pathPrefixes));
1603
+ }
1604
+ if (options.tags.length > 0) {
1605
+ conditions.push(`NOT EXISTS (
1606
+ SELECT 1 FROM json_each(?) requested_tag
1607
+ WHERE NOT EXISTS (
1608
+ SELECT 1 FROM doc_tags dt
1609
+ WHERE dt.document_id = d.id AND dt.tag = requested_tag.value
1610
+ )
1611
+ )`);
1612
+ params.push(JSON.stringify(options.tags));
1613
+ }
1614
+ const where = conditions.join(" AND ");
1615
+ const total =
1616
+ db
1617
+ .query<{ count: number }, (string | number)[]>(
1618
+ `SELECT COUNT(*) AS count FROM documents d WHERE ${where}`
1619
+ )
1620
+ .get(...params)?.count ?? 0;
1621
+ const rows = db
1622
+ .query<DbDocumentRow, (string | number)[]>(
1623
+ `SELECT d.* FROM documents d
1624
+ WHERE ${where}
1625
+ ORDER BY d.id
1626
+ LIMIT ?`
1627
+ )
1628
+ .all(...params, options.limit);
1629
+ return ok({ documents: rows.map(mapDocumentRow), total });
1630
+ } catch (cause) {
1631
+ return err(
1632
+ "QUERY_FAILED",
1633
+ cause instanceof Error
1634
+ ? cause.message
1635
+ : "Failed to select bounded audit documents",
1636
+ cause
1637
+ );
1638
+ }
1639
+ }
1640
+
1562
1641
  async listRecordDocuments(
1563
1642
  collection: string,
1564
1643
  sourcePath: string
@@ -4675,9 +4754,13 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
4675
4754
  match_count: number | null;
4676
4755
  }
4677
4756
 
4678
- interface UnresolvedByTypeRow {
4757
+ interface GraphLinkResolutionRow {
4758
+ source_id: number;
4759
+ source_docid: string;
4760
+ source_collection: string;
4761
+ target_ref_norm: string;
4762
+ target_collection: string | null;
4679
4763
  link_type: "wiki" | "markdown";
4680
- unresolved: number;
4681
4764
  }
4682
4765
 
4683
4766
  interface NodeMetaRow {
@@ -4689,104 +4772,68 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
4689
4772
  rel_path: string;
4690
4773
  }
4691
4774
 
4692
- const edgeParams: string[] = [];
4693
- let edgeCollectionClause = "";
4775
+ const linkParams: string[] = [];
4776
+ let sourceCollectionClause = "";
4694
4777
  if (collection) {
4695
- edgeCollectionClause = "AND src.collection = ? AND tgt.collection = ?";
4696
- edgeParams.push(collection, collection);
4778
+ sourceCollectionClause = "AND src.collection = ?";
4779
+ linkParams.push(collection);
4697
4780
  }
4698
4781
 
4699
- const resolvedEdgeQuery = `
4782
+ const graphLinkRows = db
4783
+ .query<GraphLinkResolutionRow, string[]>(
4784
+ `
4700
4785
  SELECT
4701
4786
  src.id as source_id,
4702
4787
  src.docid as source_docid,
4703
- tgt.id as target_id,
4704
- tgt.docid as target_docid,
4705
- dl.link_type,
4706
- CASE dl.link_type
4707
- WHEN 'wiki' THEN (${buildWikiBestRankSubquery(
4708
- "COALESCE(dl.target_collection, src.collection)",
4709
- "dl.target_ref_norm"
4710
- )})
4711
- WHEN 'markdown' THEN 5
4712
- END as match_rank,
4713
- CASE dl.link_type
4714
- WHEN 'wiki' THEN (${buildWikiBestRankMatchCountSubquery(
4715
- "COALESCE(dl.target_collection, src.collection)",
4716
- "dl.target_ref_norm"
4717
- )})
4718
- WHEN 'markdown' THEN 1
4719
- END as match_count
4788
+ src.collection as source_collection,
4789
+ dl.target_ref_norm,
4790
+ dl.target_collection,
4791
+ dl.link_type
4720
4792
  FROM documents src
4721
4793
  JOIN doc_links dl ON dl.source_doc_id = src.id
4722
- JOIN documents tgt ON tgt.id = CASE dl.link_type
4723
- WHEN 'wiki' THEN (${buildWikiBestMatchSubquery(
4724
- "COALESCE(dl.target_collection, src.collection)",
4725
- "dl.target_ref_norm"
4726
- )})
4727
- WHEN 'markdown' THEN (
4728
- SELECT md.id FROM documents md
4729
- WHERE md.active = 1
4730
- AND md.collection = COALESCE(dl.target_collection, src.collection)
4731
- AND md.rel_path = dl.target_ref_norm
4732
- ORDER BY md.id LIMIT 1
4733
- )
4734
- END
4735
- WHERE src.active = 1 AND tgt.active = 1
4736
- ${edgeCollectionClause}
4737
- ORDER BY src.id ASC, tgt.id ASC, dl.link_type ASC
4738
- `;
4739
-
4740
- const resolvedEdgeRows = db
4741
- .query<ResolvedEdgeRow, string[]>(resolvedEdgeQuery)
4742
- .all(...edgeParams);
4743
-
4744
- const unresolvedParams: string[] = [];
4745
- let unresolvedCollectionClause = "";
4746
- if (collection) {
4747
- unresolvedCollectionClause = "AND src.collection = ?";
4748
- unresolvedParams.push(collection);
4749
- }
4750
- const unresolvedQuery = `
4751
- SELECT
4752
- link_type,
4753
- COUNT(*) as unresolved
4754
- FROM (
4755
- SELECT
4756
- dl.link_type,
4757
- CASE dl.link_type
4758
- WHEN 'wiki' THEN (
4759
- ${buildWikiBestMatchSubquery(
4760
- "COALESCE(dl.target_collection, src.collection)",
4761
- "dl.target_ref_norm"
4762
- )}
4763
- )
4764
- WHEN 'markdown' THEN (
4765
- SELECT t.id FROM documents t
4766
- WHERE t.active = 1
4767
- AND t.collection = COALESCE(dl.target_collection, src.collection)
4768
- AND t.rel_path = dl.target_ref_norm
4769
- ORDER BY t.id LIMIT 1
4770
- )
4771
- END as target_id
4772
- FROM documents src
4773
- JOIN doc_links dl ON dl.source_doc_id = src.id
4774
- WHERE src.active = 1
4775
- ${unresolvedCollectionClause}
4794
+ WHERE src.active = 1
4795
+ ${sourceCollectionClause}
4796
+ ORDER BY src.id ASC, dl.id ASC
4797
+ `
4776
4798
  )
4777
- WHERE target_id IS NULL
4778
- GROUP BY link_type
4779
- `;
4780
- const unresolvedRows = db
4781
- .query<UnresolvedByTypeRow, string[]>(unresolvedQuery)
4782
- .all(...unresolvedParams);
4799
+ .all(...linkParams);
4800
+ const resolutions = resolveGraphLinkTargets(
4801
+ db,
4802
+ graphLinkRows.map((row) => ({
4803
+ targetRefNorm: row.target_ref_norm,
4804
+ targetCollection: row.target_collection ?? row.source_collection,
4805
+ linkType: row.link_type,
4806
+ }))
4807
+ );
4808
+ const resolvedEdgeRows: ResolvedEdgeRow[] = [];
4783
4809
  const unresolvedByType: Record<"wiki" | "markdown", number> = {
4784
4810
  wiki: 0,
4785
4811
  markdown: 0,
4786
4812
  };
4787
- for (const row of unresolvedRows) {
4788
- unresolvedByType[row.link_type] = row.unresolved;
4813
+ for (const [index, row] of graphLinkRows.entries()) {
4814
+ const resolution = resolutions[index];
4815
+ if (!resolution) {
4816
+ unresolvedByType[row.link_type] += 1;
4817
+ continue;
4818
+ }
4819
+ const targetCollection = row.target_collection ?? row.source_collection;
4820
+ if (collection && targetCollection !== collection) continue;
4821
+ resolvedEdgeRows.push({
4822
+ source_id: row.source_id,
4823
+ source_docid: row.source_docid,
4824
+ target_id: resolution.targetId,
4825
+ target_docid: resolution.targetDocid,
4826
+ link_type: row.link_type,
4827
+ match_rank: resolution.matchRank,
4828
+ match_count: resolution.matchCount,
4829
+ });
4789
4830
  }
4831
+ resolvedEdgeRows.sort(
4832
+ (left, right) =>
4833
+ left.source_id - right.source_id ||
4834
+ left.target_id - right.target_id ||
4835
+ left.link_type.localeCompare(right.link_type)
4836
+ );
4790
4837
  const totalEdgesUnresolved =
4791
4838
  unresolvedByType.wiki + unresolvedByType.markdown;
4792
4839
 
@@ -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
+ };