@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.
@@ -736,6 +736,31 @@ export function handleHealth(): Response {
736
736
  return jsonResponse({ ok: true });
737
737
  }
738
738
 
739
+ const statusBuilds = new WeakMap<
740
+ ServerContext,
741
+ Promise<Awaited<ReturnType<typeof buildAppStatus>>>
742
+ >();
743
+
744
+ async function getCoalescedAppStatus(
745
+ ctx: ServerContext,
746
+ deps?: StatusBuildDeps
747
+ ): Promise<Awaited<ReturnType<typeof buildAppStatus>>> {
748
+ const existing = statusBuilds.get(ctx);
749
+ if (existing) {
750
+ return existing;
751
+ }
752
+
753
+ const build = buildAppStatus(ctx, deps ?? {});
754
+ statusBuilds.set(ctx, build);
755
+ try {
756
+ return await build;
757
+ } finally {
758
+ if (statusBuilds.get(ctx) === build) {
759
+ statusBuilds.delete(ctx);
760
+ }
761
+ }
762
+ }
763
+
739
764
  /**
740
765
  * GET /api/status
741
766
  * Returns index status matching status.schema.json.
@@ -745,7 +770,7 @@ export async function handleStatus(
745
770
  deps?: StatusBuildDeps
746
771
  ): Promise<Response> {
747
772
  try {
748
- const status = await buildAppStatus(ctx, deps);
773
+ const status = await getCoalescedAppStatus(ctx, deps);
749
774
  return jsonResponse(status);
750
775
  } catch (error) {
751
776
  return errorResponse(
@@ -163,6 +163,10 @@ export async function startServer(
163
163
 
164
164
  process.once("SIGINT", shutdown);
165
165
  process.once("SIGTERM", shutdown);
166
+ const removeShutdownHandlers = (): void => {
167
+ process.off("SIGINT", shutdown);
168
+ process.off("SIGTERM", shutdown);
169
+ };
166
170
 
167
171
  // Start server with try/catch for port-in-use etc.
168
172
  let server: ReturnType<typeof Bun.serve>;
@@ -679,6 +683,7 @@ export async function startServer(
679
683
  },
680
684
  });
681
685
  } catch (e) {
686
+ removeShutdownHandlers();
682
687
  await runtime.dispose();
683
688
  return {
684
689
  success: false,
@@ -696,7 +701,11 @@ export async function startServer(
696
701
  });
697
702
  });
698
703
 
699
- await server.stop(true);
700
- await runtime.dispose();
704
+ removeShutdownHandlers();
705
+ try {
706
+ await server.stop(true);
707
+ } finally {
708
+ await runtime.dispose();
709
+ }
701
710
  return { success: true };
702
711
  }
@@ -381,6 +381,29 @@ async function buildModelCheck(
381
381
  };
382
382
  }
383
383
 
384
+ function buildVectorCheck(ctx: ServerContext): HealthCheck | null {
385
+ const vectorIndex = ctx.vectorIndex;
386
+ if (!vectorIndex || vectorIndex.searchAvailable) {
387
+ return null;
388
+ }
389
+
390
+ const reason = vectorIndex.loadError?.trim();
391
+ const guidance = vectorIndex.guidance?.trim();
392
+ return {
393
+ id: "vector-runtime",
394
+ title: "Vector search",
395
+ status: "warn",
396
+ summary: "Semantic search acceleration is unavailable",
397
+ detail: [
398
+ reason ? `sqlite-vec failed to load: ${reason}` : null,
399
+ guidance ?? "Run `gno doctor` for recovery guidance.",
400
+ "BM25 search remains available.",
401
+ ]
402
+ .filter((part): part is string => part !== null)
403
+ .join(" "),
404
+ };
405
+ }
406
+
384
407
  async function buildDiskCheck(
385
408
  status: IndexStatus,
386
409
  deps: StatusBuildDeps
@@ -636,6 +659,7 @@ export async function buildAppStatus(
636
659
  buildCollectionCheck(status),
637
660
  buildIndexingCheck(status),
638
661
  modelCheck,
662
+ buildVectorCheck(ctx),
639
663
  diskCheck,
640
664
  backgroundCheck,
641
665
  ].filter((check): check is HealthCheck => check !== null);
@@ -209,9 +209,10 @@ export class CollectionWatchService {
209
209
  collection: collection.name,
210
210
  relPaths,
211
211
  });
212
- const result = await defaultSyncService.syncCollection(
212
+ const result = await defaultSyncService.syncPaths(
213
213
  collection,
214
214
  this.#store,
215
+ relPaths,
215
216
  {
216
217
  ...this.#syncOptions,
217
218
  runUpdateCmd: false,
@@ -3014,18 +3014,35 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3014
3014
  }
3015
3015
  }
3016
3016
 
3017
- async backfillDocEdges(): Promise<StoreResult<{ inserted: number }>> {
3017
+ async backfillDocEdges(
3018
+ sourceDocumentIds?: number[]
3019
+ ): Promise<StoreResult<{ inserted: number }>> {
3018
3020
  try {
3019
3021
  const db = this.ensureOpen();
3022
+ const sourceIds = sourceDocumentIds
3023
+ ? [...new Set(sourceDocumentIds)].filter((id) => id > 0)
3024
+ : undefined;
3025
+ if (sourceIds?.length === 0) {
3026
+ return ok({ inserted: 0 });
3027
+ }
3028
+ const sourcePlaceholders = sourceIds
3029
+ ? sourceIds.map(() => "?").join(", ")
3030
+ : "";
3031
+ const sourceFilter = sourceIds
3032
+ ? `AND src.id IN (${sourcePlaceholders})`
3033
+ : "";
3020
3034
  let inserted = 0;
3021
3035
 
3022
3036
  const transaction = db.transaction(() => {
3023
- db.run("DELETE FROM doc_edges WHERE source IN (?, ?)", [
3024
- "wikilink",
3025
- "markdown-link",
3026
- ]);
3037
+ db.run(
3038
+ `DELETE FROM doc_edges
3039
+ WHERE source IN (?, ?)
3040
+ ${sourceIds ? `AND src_doc_id IN (${sourcePlaceholders})` : ""}`,
3041
+ ["wikilink", "markdown-link", ...(sourceIds ?? [])]
3042
+ );
3027
3043
 
3028
- const insertWiki = db.run(`
3044
+ const insertWiki = db.run(
3045
+ `
3029
3046
  INSERT OR IGNORE INTO doc_edges (
3030
3047
  src_doc_id, dst_doc_id, edge_type, confidence, source
3031
3048
  )
@@ -3044,9 +3061,13 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3044
3061
  WHERE src.active = 1
3045
3062
  AND tgt.active = 1
3046
3063
  AND dl.link_type = 'wiki'
3047
- `);
3064
+ ${sourceFilter}
3065
+ `,
3066
+ sourceIds ?? []
3067
+ );
3048
3068
 
3049
- const insertMarkdown = db.run(`
3069
+ const insertMarkdown = db.run(
3070
+ `
3050
3071
  INSERT OR IGNORE INTO doc_edges (
3051
3072
  src_doc_id, dst_doc_id, edge_type, confidence, source
3052
3073
  )
@@ -3063,7 +3084,10 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3063
3084
  AND tgt.rel_path = dl.target_ref_norm
3064
3085
  WHERE src.active = 1
3065
3086
  AND dl.link_type = 'markdown'
3066
- `);
3087
+ ${sourceFilter}
3088
+ `,
3089
+ sourceIds ?? []
3090
+ );
3067
3091
 
3068
3092
  inserted = insertWiki.changes + insertMarkdown.changes;
3069
3093
  });
@@ -3804,36 +3828,56 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3804
3828
  const collectionStats = db
3805
3829
  .query<CollectionStat, [string | null, string | null, string | null]>(
3806
3830
  `
3831
+ WITH document_stats AS (
3832
+ SELECT
3833
+ collection,
3834
+ COUNT(*) AS total,
3835
+ SUM(CASE WHEN active = 1 THEN 1 ELSE 0 END) AS active,
3836
+ SUM(CASE WHEN last_error_code IS NOT NULL THEN 1 ELSE 0 END) AS errored,
3837
+ SUM(CASE WHEN mirror_hash IS NOT NULL THEN 1 ELSE 0 END) AS chunked
3838
+ FROM documents
3839
+ GROUP BY collection
3840
+ ),
3841
+ active_collection_mirrors AS (
3842
+ SELECT DISTINCT collection, mirror_hash
3843
+ FROM documents
3844
+ WHERE active = 1 AND mirror_hash IS NOT NULL
3845
+ ),
3846
+ matching_vectors AS (
3847
+ SELECT mirror_hash, seq, MAX(embedded_at) AS embedded_at
3848
+ FROM content_vectors
3849
+ WHERE (? IS NULL OR (
3850
+ model = ? AND embed_fingerprint = ?
3851
+ ))
3852
+ GROUP BY mirror_hash, seq
3853
+ ),
3854
+ collection_chunks AS (
3855
+ SELECT
3856
+ acm.collection,
3857
+ COUNT(*) AS chunk_count,
3858
+ SUM(CASE
3859
+ WHEN mv.embedded_at >= cc.created_at THEN 1
3860
+ ELSE 0
3861
+ END) AS embedded_count
3862
+ FROM active_collection_mirrors acm
3863
+ JOIN content_chunks cc ON cc.mirror_hash = acm.mirror_hash
3864
+ LEFT JOIN matching_vectors mv
3865
+ ON mv.mirror_hash = cc.mirror_hash AND mv.seq = cc.seq
3866
+ GROUP BY acm.collection
3867
+ )
3807
3868
  SELECT
3808
3869
  c.name,
3809
3870
  c.path,
3810
- COUNT(DISTINCT d.id) as total,
3811
- SUM(CASE WHEN d.active = 1 THEN 1 ELSE 0 END) as active,
3812
- SUM(CASE WHEN d.last_error_code IS NOT NULL THEN 1 ELSE 0 END) as errored,
3813
- SUM(CASE WHEN d.mirror_hash IS NOT NULL THEN 1 ELSE 0 END) as chunked,
3814
- (SELECT COUNT(*) FROM content_chunks cc
3815
- JOIN documents d2 ON d2.mirror_hash = cc.mirror_hash
3816
- WHERE d2.collection = c.name AND d2.active = 1) as chunk_count,
3817
- (SELECT COUNT(*) FROM content_chunks cc
3818
- WHERE EXISTS (
3819
- SELECT 1 FROM documents d3
3820
- WHERE d3.collection = c.name
3821
- AND d3.active = 1
3822
- AND d3.mirror_hash = cc.mirror_hash
3823
- )
3824
- AND EXISTS (
3825
- SELECT 1 FROM content_vectors cv
3826
- WHERE cv.mirror_hash = cc.mirror_hash
3827
- AND cv.seq = cc.seq
3828
- AND (? IS NULL OR (
3829
- cv.model = ?
3830
- AND cv.embed_fingerprint = ?
3831
- ))
3832
- AND cv.embedded_at >= cc.created_at
3833
- )) as embedded_count
3871
+ COALESCE(ds.total, 0) AS total,
3872
+ COALESCE(ds.active, 0) AS active,
3873
+ COALESCE(ds.errored, 0) AS errored,
3874
+ COALESCE(ds.chunked, 0) AS chunked,
3875
+ COALESCE(ch.chunk_count, 0) AS chunk_count,
3876
+ COALESCE(ch.embedded_count, 0) AS embedded_count
3834
3877
  FROM collections c
3835
- LEFT JOIN documents d ON d.collection = c.name
3836
- GROUP BY c.name, c.path
3878
+ LEFT JOIN document_stats ds ON ds.collection = c.name
3879
+ LEFT JOIN collection_chunks ch ON ch.collection = c.name
3880
+ ORDER BY c.name
3837
3881
  `
3838
3882
  )
3839
3883
  .all(embedModel, embedModel, embedFingerprint);
@@ -3865,21 +3909,27 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3865
3909
  [string | null, string | null, string | null]
3866
3910
  >(
3867
3911
  `
3868
- SELECT COUNT(*) as count FROM content_chunks c
3869
- WHERE EXISTS (
3870
- SELECT 1 FROM documents d
3871
- WHERE d.mirror_hash = c.mirror_hash AND d.active = 1
3872
- )
3873
- AND NOT EXISTS (
3874
- SELECT 1 FROM content_vectors v
3875
- WHERE v.mirror_hash = c.mirror_hash
3876
- AND v.seq = c.seq
3877
- AND (? IS NULL OR (
3878
- v.model = ?
3879
- AND v.embed_fingerprint = ?
3880
- ))
3881
- AND v.embedded_at >= c.created_at
3912
+ WITH active_mirrors AS (
3913
+ SELECT DISTINCT mirror_hash
3914
+ FROM documents
3915
+ WHERE active = 1 AND mirror_hash IS NOT NULL
3916
+ ),
3917
+ matching_vectors AS (
3918
+ SELECT mirror_hash, seq, MAX(embedded_at) AS embedded_at
3919
+ FROM content_vectors
3920
+ WHERE (? IS NULL OR (
3921
+ model = ? AND embed_fingerprint = ?
3922
+ ))
3923
+ GROUP BY mirror_hash, seq
3882
3924
  )
3925
+ SELECT COUNT(*) AS count
3926
+ FROM active_mirrors am
3927
+ JOIN content_chunks c ON c.mirror_hash = am.mirror_hash
3928
+ LEFT JOIN matching_vectors mv
3929
+ ON mv.mirror_hash = c.mirror_hash
3930
+ AND mv.seq = c.seq
3931
+ AND mv.embedded_at >= c.created_at
3932
+ WHERE mv.mirror_hash IS NULL
3883
3933
  `
3884
3934
  )
3885
3935
  .get(embedModel, embedModel, embedFingerprint);
@@ -0,0 +1,68 @@
1
+ import type { Config } from "../../config/types";
2
+
3
+ import { getIndexDbPath } from "../../app/constants";
4
+ import { indexesMatch } from "../../core/indexed-reference";
5
+ import { SqliteAdapter } from "./adapter";
6
+
7
+ export interface ScopedIndexStore {
8
+ store: SqliteAdapter;
9
+ indexName?: string;
10
+ owned: boolean;
11
+ close(): Promise<void>;
12
+ }
13
+
14
+ export async function openScopedIndexStore(options: {
15
+ activeStore: SqliteAdapter;
16
+ activeIndexName?: string;
17
+ requestedIndexName?: string;
18
+ config: Config;
19
+ configPath: string | null;
20
+ }): Promise<ScopedIndexStore> {
21
+ const requestedIndexName =
22
+ options.requestedIndexName ?? options.activeIndexName;
23
+ if (indexesMatch(requestedIndexName, options.activeIndexName)) {
24
+ return {
25
+ store: options.activeStore,
26
+ indexName: requestedIndexName,
27
+ owned: false,
28
+ close: () => Promise.resolve(),
29
+ };
30
+ }
31
+
32
+ const dbPath = getIndexDbPath(requestedIndexName);
33
+ if (!(await Bun.file(dbPath).exists())) {
34
+ throw new Error(
35
+ `Index "${requestedIndexName}" does not exist at ${dbPath}`
36
+ );
37
+ }
38
+
39
+ const store = new SqliteAdapter();
40
+ store.setConfigPath(options.configPath ?? "<inline-config>");
41
+ const openResult = await store.open(dbPath, options.config.ftsTokenizer);
42
+ if (!openResult.ok) {
43
+ throw new Error(openResult.error.message);
44
+ }
45
+
46
+ const collectionsResult = await store.syncCollections(
47
+ options.config.collections
48
+ );
49
+ if (!collectionsResult.ok) {
50
+ await store.close();
51
+ throw new Error(collectionsResult.error.message);
52
+ }
53
+
54
+ const contextsResult = await store.syncContexts(
55
+ options.config.contexts ?? []
56
+ );
57
+ if (!contextsResult.ok) {
58
+ await store.close();
59
+ throw new Error(contextsResult.error.message);
60
+ }
61
+
62
+ return {
63
+ store,
64
+ indexName: requestedIndexName,
65
+ owned: true,
66
+ close: () => store.close(),
67
+ };
68
+ }
@@ -1110,7 +1110,9 @@ export interface StorePort {
1110
1110
  /**
1111
1111
  * Rebuild derived semantic edges from currently indexed links.
1112
1112
  */
1113
- backfillDocEdges(): Promise<StoreResult<{ inserted: number }>>;
1113
+ backfillDocEdges(
1114
+ sourceDocumentIds?: number[]
1115
+ ): Promise<StoreResult<{ inserted: number }>>;
1114
1116
 
1115
1117
  // ─────────────────────────────────────────────────────────────────────────
1116
1118
  // Graph