@gmickel/gno 1.12.2 → 1.12.3

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.
package/README.md CHANGED
@@ -197,7 +197,7 @@ Manage the detached process with `gno daemon --status` and `gno daemon --stop`.
197
197
 
198
198
  ### Install GNO
199
199
 
200
- Requires [Bun](https://bun.sh/) >= 1.0.0.
200
+ Requires [Bun](https://bun.sh/) >= 1.3.0.
201
201
 
202
202
  ```bash
203
203
  bun install -g @gmickel/gno
@@ -227,7 +227,9 @@ gno graph --from gno://notes/a.md --to gno://notes/b.md
227
227
  ```
228
228
 
229
229
  Non-default index search results may include `?index=<name>` on `gno://` URIs.
230
- Keep that query string when passing the URI to `gno get`.
230
+ Keep that query string when passing the URI to `gno get`, SDK `get()`, MCP
231
+ `gno_get`, or an MCP resource read: it selects the named database. Batch reads
232
+ must contain refs for one index; split mixed-index results before `multi-get`.
231
233
 
232
234
  ## Important: Embedding After Changes
233
235
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "1.12.2",
3
+ "version": "1.12.3",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -71,6 +71,7 @@
71
71
  "eval:hybrid:baseline": "bun scripts/hybrid-benchmark.ts --write",
72
72
  "eval:hybrid:delta": "bun scripts/hybrid-benchmark.ts --delta",
73
73
  "bench:ast-chunking": "bun scripts/ast-chunking-benchmark.ts",
74
+ "smoke:serve-shutdown": "bun scripts/serve-shutdown-smoke.ts",
74
75
  "bench:code-embeddings": "bun scripts/code-embedding-benchmark.ts",
75
76
  "bench:code-embeddings:write": "bun scripts/code-embedding-benchmark.ts --write",
76
77
  "bench:general-embeddings": "bun scripts/general-embedding-benchmark.ts",
@@ -0,0 +1,68 @@
1
+ import { DEFAULT_INDEX_NAME, parseUri } from "../app/constants";
2
+ import { parseRef } from "./ref-parser";
3
+
4
+ export interface EffectiveIndexResolution {
5
+ indexName?: string;
6
+ }
7
+
8
+ function normalizeIndexName(indexName?: string): string {
9
+ const normalized = indexName?.trim();
10
+ return normalized || DEFAULT_INDEX_NAME;
11
+ }
12
+
13
+ export function indexesMatch(left?: string, right?: string): boolean {
14
+ return normalizeIndexName(left) === normalizeIndexName(right);
15
+ }
16
+
17
+ export function getExplicitRefIndex(ref: string): string | undefined {
18
+ const parsed = parseRef(ref);
19
+ if ("error" in parsed || parsed.type !== "uri") {
20
+ return;
21
+ }
22
+ return parseUri(parsed.value)?.indexName;
23
+ }
24
+
25
+ export function resolveEffectiveIndex(
26
+ refs: string[],
27
+ activeIndexName?: string
28
+ ):
29
+ | { ok: true; value: EffectiveIndexResolution }
30
+ | { ok: false; error: string } {
31
+ const explicitIndexes = new Set<string>();
32
+ let hasUnindexedRef = false;
33
+
34
+ for (const ref of refs) {
35
+ const explicitIndex = getExplicitRefIndex(ref);
36
+ if (explicitIndex) {
37
+ explicitIndexes.add(explicitIndex);
38
+ } else {
39
+ hasUnindexedRef = true;
40
+ }
41
+ }
42
+
43
+ if (explicitIndexes.size > 1) {
44
+ return {
45
+ ok: false,
46
+ error: `References cannot mix explicit indexes: ${[...explicitIndexes]
47
+ .sort()
48
+ .join(", ")}`,
49
+ };
50
+ }
51
+
52
+ const explicitIndex = [...explicitIndexes][0];
53
+ if (
54
+ explicitIndex &&
55
+ hasUnindexedRef &&
56
+ !indexesMatch(explicitIndex, activeIndexName)
57
+ ) {
58
+ return {
59
+ ok: false,
60
+ error: `References cannot mix indexed refs (${explicitIndex}) with unindexed refs while the active index is ${normalizeIndexName(activeIndexName)}`,
61
+ };
62
+ }
63
+
64
+ return {
65
+ ok: true,
66
+ value: { indexName: explicitIndex ?? activeIndexName },
67
+ };
68
+ }
@@ -141,5 +141,10 @@ export function splitRefs(refs: string[]): string[] {
141
141
  * Check if a ref contains glob characters.
142
142
  */
143
143
  export function isGlobPattern(ref: string): boolean {
144
- return GLOB_PATTERN.test(ref);
144
+ const queryIndex = ref.indexOf("?");
145
+ const globCandidate =
146
+ ref.startsWith("gno://") && queryIndex >= 0
147
+ ? ref.slice(0, queryIndex)
148
+ : ref;
149
+ return GLOB_PATTERN.test(globCandidate);
145
150
  }
package/src/index.ts CHANGED
@@ -20,8 +20,17 @@ async function cleanupAndExit(code: number): Promise<never> {
20
20
  process.exit(code);
21
21
  }
22
22
 
23
- // SIGINT handler for graceful shutdown
23
+ let interruptExitCode: 0 | 130 = 0;
24
+
25
+ // Long-running commands install their own SIGINT handler and must finish their
26
+ // resource teardown before this bootstrap exits. Short-lived commands have no
27
+ // owner, so retain the immediate interrupt behavior for them.
24
28
  process.on("SIGINT", () => {
29
+ if (process.listenerCount("SIGINT") > 1) {
30
+ return;
31
+ }
32
+
33
+ interruptExitCode = 130;
25
34
  process.stderr.write("\nInterrupted\n");
26
35
  cleanupAndExit(130).catch(() => {
27
36
  // Ignore cleanup errors on exit
@@ -30,7 +39,7 @@ process.on("SIGINT", () => {
30
39
 
31
40
  // Run CLI and exit
32
41
  runCli(process.argv)
33
- .then((code) => cleanupAndExit(code))
42
+ .then((code) => cleanupAndExit(interruptExitCode || code))
34
43
  .catch((err) => {
35
44
  process.stderr.write(
36
45
  `Fatal error: ${err instanceof Error ? err.message : String(err)}\n`
@@ -75,6 +75,7 @@ const MAX_CONCURRENCY = 16;
75
75
  export const INGEST_VERSION = 6;
76
76
  const EMPTY_CONTENT_TYPE_RULES_FINGERPRINT = fingerprintContentTypeRules([]);
77
77
  const RELATION_EDGE_TYPE_PATTERN = /^[a-z][a-z0-9_]*$/;
78
+ const PROJECTION_YIELD_INTERVAL = 25;
78
79
  const NON_RETRYABLE_CONVERSION_ERROR_CODES = new Set([
79
80
  "CORRUPT",
80
81
  "PERMISSION",
@@ -1000,19 +1001,83 @@ export class SyncService {
1000
1001
  relPaths: string[],
1001
1002
  options: SyncOptions = {}
1002
1003
  ): Promise<FileSyncResult[]> {
1004
+ const result = await this.syncPaths(collection, store, relPaths, options);
1005
+ return result.files ?? [];
1006
+ }
1007
+
1008
+ async syncPaths(
1009
+ collection: Collection,
1010
+ store: StorePort,
1011
+ relPaths: string[],
1012
+ options: SyncOptions = {}
1013
+ ): Promise<CollectionSyncResult> {
1014
+ const startedAt = Date.now();
1015
+ const syncOptions: SyncOptions = {
1016
+ ...options,
1017
+ contentTypeRules: options.contentTypeRules ?? [],
1018
+ contentTypeRulesFingerprint:
1019
+ options.contentTypeRulesFingerprint ??
1020
+ fingerprintContentTypeRules(options.contentTypeRules ?? []),
1021
+ };
1003
1022
  const results: FileSyncResult[] = [];
1023
+ const projectionSourceIds = new Set<number>();
1024
+ let markedInactive = 0;
1004
1025
 
1005
1026
  for (const relPath of relPaths) {
1027
+ const existingResult = await store.getDocument(collection.name, relPath);
1028
+ const existingDoc = existingResult.ok ? existingResult.value : null;
1029
+ if (existingDoc) {
1030
+ await this.collectProjectionSourceIds(
1031
+ store,
1032
+ existingDoc.id,
1033
+ projectionSourceIds
1034
+ );
1035
+ }
1036
+
1006
1037
  const absPath = join(collection.path, relPath);
1007
1038
  let stats: Awaited<ReturnType<typeof stat>>;
1008
1039
  try {
1009
1040
  stats = await stat(absPath);
1010
- } catch {
1041
+ } catch (error) {
1042
+ const errorCode =
1043
+ error && typeof error === "object" && "code" in error
1044
+ ? String(error.code)
1045
+ : undefined;
1046
+ if (errorCode !== "ENOENT") {
1047
+ results.push({
1048
+ relPath,
1049
+ status: "error",
1050
+ errorCode: "STAT_FAILED",
1051
+ errorMessage:
1052
+ error instanceof Error ? error.message : "Failed to stat file",
1053
+ });
1054
+ continue;
1055
+ }
1056
+ if (existingDoc?.active) {
1057
+ const inactiveResult = await store.markInactive(collection.name, [
1058
+ relPath,
1059
+ ]);
1060
+ if (!inactiveResult.ok) {
1061
+ results.push({
1062
+ relPath,
1063
+ status: "error",
1064
+ errorCode: inactiveResult.error.code,
1065
+ errorMessage: inactiveResult.error.message,
1066
+ });
1067
+ continue;
1068
+ }
1069
+ markedInactive += inactiveResult.value;
1070
+ results.push({
1071
+ relPath,
1072
+ status: "updated",
1073
+ docid: existingDoc.docid,
1074
+ });
1075
+ continue;
1076
+ }
1011
1077
  results.push({
1012
1078
  relPath,
1013
- status: "error",
1014
- errorCode: "NOT_FOUND",
1015
- errorMessage: "File not found",
1079
+ status: existingDoc ? "unchanged" : "skipped",
1080
+ docid: existingDoc?.docid,
1016
1081
  });
1017
1082
  continue;
1018
1083
  }
@@ -1035,24 +1100,91 @@ export class SyncService {
1035
1100
  ctime: (stats.birthtime ?? stats.ctime ?? stats.mtime).toISOString(),
1036
1101
  };
1037
1102
 
1038
- const result = await this.processFile(collection, entry, store, options);
1103
+ const result = await this.processFile(
1104
+ collection,
1105
+ entry,
1106
+ store,
1107
+ syncOptions
1108
+ );
1039
1109
  results.push(result);
1110
+ const currentResult = await store.getDocument(collection.name, relPath);
1111
+ const currentDoc = currentResult.ok ? currentResult.value : null;
1112
+ if (currentDoc?.active) {
1113
+ await this.collectProjectionSourceIds(
1114
+ store,
1115
+ currentDoc.id,
1116
+ projectionSourceIds
1117
+ );
1118
+ }
1040
1119
  }
1041
1120
 
1042
- await this.projectTypedEdges(collection, store, options);
1121
+ const errors =
1122
+ syncOptions.projectTypedEdges === false
1123
+ ? []
1124
+ : await this.projectTypedEdges(store, syncOptions, projectionSourceIds);
1125
+ const added = results.filter((result) => result.status === "added").length;
1126
+ const updated = results.filter(
1127
+ (result) => result.status === "updated"
1128
+ ).length;
1129
+ const unchanged = results.filter(
1130
+ (result) => result.status === "unchanged"
1131
+ ).length;
1132
+ const errored = results.filter(
1133
+ (result) => result.status === "error"
1134
+ ).length;
1135
+ const skipped = results.filter(
1136
+ (result) => result.status === "skipped"
1137
+ ).length;
1043
1138
 
1044
- return results;
1139
+ return {
1140
+ collection: collection.name,
1141
+ filesProcessed: results.length,
1142
+ filesAdded: added,
1143
+ filesUpdated: updated,
1144
+ filesUnchanged: unchanged,
1145
+ filesErrored: errored,
1146
+ filesSkipped: skipped,
1147
+ filesMarkedInactive: markedInactive,
1148
+ durationMs: Date.now() - startedAt,
1149
+ files: results,
1150
+ errors,
1151
+ };
1152
+ }
1153
+
1154
+ private async collectProjectionSourceIds(
1155
+ store: StorePort,
1156
+ documentId: number,
1157
+ sourceIds: Set<number>
1158
+ ): Promise<void> {
1159
+ sourceIds.add(documentId);
1160
+ const [linkBacklinks, edgeBacklinks] = await Promise.all([
1161
+ store.getBacklinksForDoc(documentId),
1162
+ store.getEdgeBacklinksForDoc(documentId),
1163
+ ]);
1164
+ if (linkBacklinks.ok) {
1165
+ for (const backlink of linkBacklinks.value) {
1166
+ sourceIds.add(backlink.sourceDocId);
1167
+ }
1168
+ }
1169
+ if (edgeBacklinks.ok) {
1170
+ for (const backlink of edgeBacklinks.value) {
1171
+ sourceIds.add(backlink.sourceDocId);
1172
+ }
1173
+ }
1045
1174
  }
1046
1175
 
1047
1176
  private async projectTypedEdges(
1048
- collection: Collection,
1049
1177
  store: StorePort,
1050
- options: SyncOptions
1178
+ options: SyncOptions,
1179
+ sourceDocumentIds?: Set<number>
1051
1180
  ): Promise<Array<{ relPath: string; code: string; message: string }>> {
1052
1181
  const errors: Array<{ relPath: string; code: string; message: string }> =
1053
1182
  [];
1054
1183
 
1055
- const backfillResult = await store.backfillDocEdges();
1184
+ const selectedSourceIds = sourceDocumentIds
1185
+ ? [...sourceDocumentIds]
1186
+ : undefined;
1187
+ const backfillResult = await store.backfillDocEdges(selectedSourceIds);
1056
1188
  if (!backfillResult.ok) {
1057
1189
  return [
1058
1190
  {
@@ -1075,8 +1207,22 @@ export class SyncService {
1075
1207
  }
1076
1208
 
1077
1209
  const activeDocs = docsResult.value.filter((doc) => doc.active);
1210
+ const activeIds = new Set(activeDocs.map((doc) => doc.id));
1211
+ if (selectedSourceIds) {
1212
+ for (const documentId of selectedSourceIds) {
1213
+ if (!activeIds.has(documentId)) {
1214
+ await store.setDocEdges(documentId, [], "frontmatter-relation");
1215
+ }
1216
+ }
1217
+ }
1218
+ const projectedDocs = sourceDocumentIds
1219
+ ? activeDocs.filter((doc) => sourceDocumentIds.has(doc.id))
1220
+ : activeDocs;
1078
1221
 
1079
- for (const doc of activeDocs) {
1222
+ for (const [docIndex, doc] of projectedDocs.entries()) {
1223
+ if (docIndex > 0 && docIndex % PROJECTION_YIELD_INTERVAL === 0) {
1224
+ await Bun.sleep(0);
1225
+ }
1080
1226
  if (!doc.mirrorHash) {
1081
1227
  continue;
1082
1228
  }
@@ -1193,6 +1339,14 @@ export class SyncService {
1193
1339
  return errors;
1194
1340
  }
1195
1341
 
1342
+ /** Run an exact global typed-edge reconciliation with cooperative yields. */
1343
+ reconcileTypedEdges(
1344
+ store: StorePort,
1345
+ options: SyncOptions = {}
1346
+ ): Promise<Array<{ relPath: string; code: string; message: string }>> {
1347
+ return this.projectTypedEdges(store, options);
1348
+ }
1349
+
1196
1350
  /**
1197
1351
  * Sync a single collection.
1198
1352
  */
@@ -1419,9 +1573,9 @@ export class SyncService {
1419
1573
  }
1420
1574
  }
1421
1575
 
1422
- errors.push(
1423
- ...(await this.projectTypedEdges(collection, store, syncOptions))
1424
- );
1576
+ if (syncOptions.projectTypedEdges !== false) {
1577
+ errors.push(...(await this.projectTypedEdges(store, syncOptions)));
1578
+ }
1425
1579
 
1426
1580
  return {
1427
1581
  collection: collection.name,
@@ -1448,12 +1602,25 @@ export class SyncService {
1448
1602
  ): Promise<SyncResult> {
1449
1603
  const startTime = Date.now();
1450
1604
  const results: CollectionSyncResult[] = [];
1605
+ const deferredProjectionOptions: SyncOptions = {
1606
+ ...options,
1607
+ projectTypedEdges: false,
1608
+ };
1451
1609
 
1452
1610
  for (const collection of collections) {
1453
- const result = await this.syncCollection(collection, store, options);
1611
+ const result = await this.syncCollection(
1612
+ collection,
1613
+ store,
1614
+ deferredProjectionOptions
1615
+ );
1454
1616
  results.push(result);
1455
1617
  }
1456
1618
 
1619
+ if (results.length > 0) {
1620
+ const projectionErrors = await this.projectTypedEdges(store, options);
1621
+ results.at(-1)?.errors.push(...projectionErrors);
1622
+ }
1623
+
1457
1624
  // Aggregate totals
1458
1625
  const totals = results.reduce(
1459
1626
  (acc, r) => ({
@@ -137,6 +137,8 @@ export interface SyncOptions {
137
137
  contentTypeRules?: NormalizedContentTypeRule[];
138
138
  /** Stable hash of the normalized content type rules, used for re-derivation. */
139
139
  contentTypeRulesFingerprint?: string;
140
+ /** Internal orchestration flag: defer graph projection to an outer sync. */
141
+ projectTypedEdges?: boolean;
140
142
  }
141
143
 
142
144
  export type ContentTypeSource =
@@ -20,8 +20,10 @@ import {
20
20
  URI_PREFIX,
21
21
  } from "../../app/constants";
22
22
  import { MCP_ERRORS } from "../../core/errors";
23
+ import { resolveEffectiveIndex } from "../../core/indexed-reference";
23
24
  import { normalizeTag, validateTag } from "../../core/tags";
24
25
  import { normalizeCollectionName } from "../../core/validation";
26
+ import { openScopedIndexStore } from "../../store/sqlite/scoped-index";
25
27
 
26
28
  // Tags resource URI prefix
27
29
  const TAGS_URI = `${URI_PREFIX}tags`;
@@ -51,7 +53,8 @@ function formatTagsContent(
51
53
  function formatResourceContent(
52
54
  doc: DocumentRow,
53
55
  content: string,
54
- ctx: ToolContext
56
+ ctx: ToolContext,
57
+ indexName = ctx.indexName
55
58
  ): string {
56
59
  // Find collection for absPath
57
60
  const uriParsed = parseUri(doc.uri);
@@ -69,7 +72,7 @@ function formatResourceContent(
69
72
  const langLine = doc.languageHint
70
73
  ? `\n language: ${doc.languageHint}`
71
74
  : "";
72
- const displayUri = decorateUriForIndex(doc.uri, ctx.indexName);
75
+ const displayUri = decorateUriForIndex(doc.uri, indexName);
73
76
  const header = `<!-- ${displayUri}
74
77
  docid: ${doc.docid}
75
78
  source: ${absPath}
@@ -125,61 +128,82 @@ export function registerResources(server: McpServer, ctx: ToolContext): void {
125
128
  throw new Error(`Invalid gno:// URI: ${uri.href}`);
126
129
  }
127
130
 
128
- const { collection, path } = parsed;
129
-
130
- // Validate collection exists
131
- const collectionExists = ctx.collections.some(
132
- (c) => c.name === collection
133
- );
134
- if (!collectionExists) {
135
- throw new Error(`Collection not found: ${collection}`);
131
+ const resolution = resolveEffectiveIndex([uri.href], ctx.indexName);
132
+ if (!resolution.ok) {
133
+ throw new Error(resolution.error);
136
134
  }
135
+ const scoped = await openScopedIndexStore({
136
+ activeStore: ctx.store,
137
+ activeIndexName: ctx.indexName,
138
+ requestedIndexName: resolution.value.indexName,
139
+ config: ctx.config,
140
+ configPath: ctx.actualConfigPath,
141
+ });
142
+
143
+ try {
144
+ const { collection, path } = parsed;
137
145
 
138
- // Look up document (path is properly decoded by parseUri)
139
- const docResult = await ctx.store.getDocument(collection, path);
140
- if (!docResult.ok) {
141
- throw new Error(
142
- `Failed to lookup document: ${docResult.error.message}`
146
+ // Validate collection exists
147
+ const collectionExists = ctx.collections.some(
148
+ (c) => c.name === collection
143
149
  );
144
- }
150
+ if (!collectionExists) {
151
+ throw new Error(`Collection not found: ${collection}`);
152
+ }
145
153
 
146
- const doc = docResult.value;
147
- if (!doc) {
148
- throw new Error(`Document not found: ${uri.href}`);
149
- }
154
+ // Look up document (path is properly decoded by parseUri)
155
+ const docResult = await scoped.store.getDocument(collection, path);
156
+ if (!docResult.ok) {
157
+ throw new Error(
158
+ `Failed to lookup document: ${docResult.error.message}`
159
+ );
160
+ }
150
161
 
151
- // Get content
152
- if (!doc.mirrorHash) {
153
- throw new Error(`Document has no indexed content: ${uri.href}`);
154
- }
162
+ const doc = docResult.value;
163
+ if (!doc) {
164
+ throw new Error(`Document not found: ${uri.href}`);
165
+ }
155
166
 
156
- const contentResult = await ctx.store.getContent(doc.mirrorHash);
157
- if (!contentResult.ok) {
158
- throw new Error(
159
- `Failed to read content: ${contentResult.error.message}`
160
- );
161
- }
167
+ // Get content
168
+ if (!doc.mirrorHash) {
169
+ throw new Error(`Document has no indexed content: ${uri.href}`);
170
+ }
162
171
 
163
- const content = contentResult.value ?? "";
172
+ const contentResult = await scoped.store.getContent(doc.mirrorHash);
173
+ if (!contentResult.ok) {
174
+ throw new Error(
175
+ `Failed to read content: ${contentResult.error.message}`
176
+ );
177
+ }
164
178
 
165
- // Format with header and line numbers
166
- const formattedContent = formatResourceContent(doc, content, ctx);
179
+ const content = contentResult.value ?? "";
167
180
 
168
- // Build canonical URI
169
- const canonicalUri = decorateUriForIndex(
170
- buildUri(collection, path),
171
- parsed.indexName ?? ctx.indexName
172
- );
181
+ // Format with header and line numbers
182
+ const formattedContent = formatResourceContent(
183
+ doc,
184
+ content,
185
+ ctx,
186
+ scoped.indexName
187
+ );
173
188
 
174
- return {
175
- contents: [
176
- {
177
- uri: canonicalUri,
178
- mimeType: "text/markdown",
179
- text: formattedContent,
180
- },
181
- ],
182
- };
189
+ // Build canonical URI
190
+ const canonicalUri = decorateUriForIndex(
191
+ buildUri(collection, path),
192
+ scoped.indexName
193
+ );
194
+
195
+ return {
196
+ contents: [
197
+ {
198
+ uri: canonicalUri,
199
+ mimeType: "text/markdown",
200
+ text: formattedContent,
201
+ },
202
+ ],
203
+ };
204
+ } finally {
205
+ await scoped.close();
206
+ }
183
207
  } finally {
184
208
  release();
185
209
  }