@gmickel/gno 1.12.2 → 1.12.4

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.
@@ -396,7 +396,7 @@ export default function Dashboard({ navigate }: PageProps) {
396
396
  </div>
397
397
 
398
398
  <div className="flex flex-wrap items-center gap-3 md:justify-end">
399
- <AIModelSelector showLabel={false} />
399
+ <AIModelSelector appStatus={status} showLabel={false} />
400
400
  <Button
401
401
  disabled={syncing}
402
402
  onClick={() => void handleSync()}
@@ -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,
@@ -278,6 +278,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
278
278
  private configPath = ""; // Set by CLI layer for status output
279
279
  private txDepth = 0; // Transaction nesting depth
280
280
  private txCounter = 0; // Savepoint counter for unique names
281
+ private contextGeneration = 0;
281
282
 
282
283
  // ─────────────────────────────────────────────────────────────────────────
283
284
  // Lifecycle
@@ -341,6 +342,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
341
342
  return result;
342
343
  }
343
344
 
345
+ this.contextGeneration += 1;
344
346
  return result;
345
347
  } catch (cause) {
346
348
  const message =
@@ -517,6 +519,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
517
519
  });
518
520
 
519
521
  transaction();
522
+ this.contextGeneration += 1;
520
523
  return ok(undefined);
521
524
  } catch (cause) {
522
525
  return err(
@@ -559,6 +562,10 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
559
562
  }
560
563
  }
561
564
 
565
+ getContextGeneration(): number {
566
+ return this.contextGeneration;
567
+ }
568
+
562
569
  // ─────────────────────────────────────────────────────────────────────────
563
570
  // Documents
564
571
  // ─────────────────────────────────────────────────────────────────────────
@@ -3014,18 +3021,35 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3014
3021
  }
3015
3022
  }
3016
3023
 
3017
- async backfillDocEdges(): Promise<StoreResult<{ inserted: number }>> {
3024
+ async backfillDocEdges(
3025
+ sourceDocumentIds?: number[]
3026
+ ): Promise<StoreResult<{ inserted: number }>> {
3018
3027
  try {
3019
3028
  const db = this.ensureOpen();
3029
+ const sourceIds = sourceDocumentIds
3030
+ ? [...new Set(sourceDocumentIds)].filter((id) => id > 0)
3031
+ : undefined;
3032
+ if (sourceIds?.length === 0) {
3033
+ return ok({ inserted: 0 });
3034
+ }
3035
+ const sourcePlaceholders = sourceIds
3036
+ ? sourceIds.map(() => "?").join(", ")
3037
+ : "";
3038
+ const sourceFilter = sourceIds
3039
+ ? `AND src.id IN (${sourcePlaceholders})`
3040
+ : "";
3020
3041
  let inserted = 0;
3021
3042
 
3022
3043
  const transaction = db.transaction(() => {
3023
- db.run("DELETE FROM doc_edges WHERE source IN (?, ?)", [
3024
- "wikilink",
3025
- "markdown-link",
3026
- ]);
3044
+ db.run(
3045
+ `DELETE FROM doc_edges
3046
+ WHERE source IN (?, ?)
3047
+ ${sourceIds ? `AND src_doc_id IN (${sourcePlaceholders})` : ""}`,
3048
+ ["wikilink", "markdown-link", ...(sourceIds ?? [])]
3049
+ );
3027
3050
 
3028
- const insertWiki = db.run(`
3051
+ const insertWiki = db.run(
3052
+ `
3029
3053
  INSERT OR IGNORE INTO doc_edges (
3030
3054
  src_doc_id, dst_doc_id, edge_type, confidence, source
3031
3055
  )
@@ -3044,9 +3068,13 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3044
3068
  WHERE src.active = 1
3045
3069
  AND tgt.active = 1
3046
3070
  AND dl.link_type = 'wiki'
3047
- `);
3071
+ ${sourceFilter}
3072
+ `,
3073
+ sourceIds ?? []
3074
+ );
3048
3075
 
3049
- const insertMarkdown = db.run(`
3076
+ const insertMarkdown = db.run(
3077
+ `
3050
3078
  INSERT OR IGNORE INTO doc_edges (
3051
3079
  src_doc_id, dst_doc_id, edge_type, confidence, source
3052
3080
  )
@@ -3063,7 +3091,10 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3063
3091
  AND tgt.rel_path = dl.target_ref_norm
3064
3092
  WHERE src.active = 1
3065
3093
  AND dl.link_type = 'markdown'
3066
- `);
3094
+ ${sourceFilter}
3095
+ `,
3096
+ sourceIds ?? []
3097
+ );
3067
3098
 
3068
3099
  inserted = insertWiki.changes + insertMarkdown.changes;
3069
3100
  });
@@ -3804,36 +3835,56 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3804
3835
  const collectionStats = db
3805
3836
  .query<CollectionStat, [string | null, string | null, string | null]>(
3806
3837
  `
3838
+ WITH document_stats AS (
3839
+ SELECT
3840
+ collection,
3841
+ COUNT(*) AS total,
3842
+ SUM(CASE WHEN active = 1 THEN 1 ELSE 0 END) AS active,
3843
+ SUM(CASE WHEN last_error_code IS NOT NULL THEN 1 ELSE 0 END) AS errored,
3844
+ SUM(CASE WHEN mirror_hash IS NOT NULL THEN 1 ELSE 0 END) AS chunked
3845
+ FROM documents
3846
+ GROUP BY collection
3847
+ ),
3848
+ active_collection_mirrors AS (
3849
+ SELECT DISTINCT collection, mirror_hash
3850
+ FROM documents
3851
+ WHERE active = 1 AND mirror_hash IS NOT NULL
3852
+ ),
3853
+ matching_vectors AS (
3854
+ SELECT mirror_hash, seq, MAX(embedded_at) AS embedded_at
3855
+ FROM content_vectors
3856
+ WHERE (? IS NULL OR (
3857
+ model = ? AND embed_fingerprint = ?
3858
+ ))
3859
+ GROUP BY mirror_hash, seq
3860
+ ),
3861
+ collection_chunks AS (
3862
+ SELECT
3863
+ acm.collection,
3864
+ COUNT(*) AS chunk_count,
3865
+ SUM(CASE
3866
+ WHEN mv.embedded_at >= cc.created_at THEN 1
3867
+ ELSE 0
3868
+ END) AS embedded_count
3869
+ FROM active_collection_mirrors acm
3870
+ JOIN content_chunks cc ON cc.mirror_hash = acm.mirror_hash
3871
+ LEFT JOIN matching_vectors mv
3872
+ ON mv.mirror_hash = cc.mirror_hash AND mv.seq = cc.seq
3873
+ GROUP BY acm.collection
3874
+ )
3807
3875
  SELECT
3808
3876
  c.name,
3809
3877
  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
3878
+ COALESCE(ds.total, 0) AS total,
3879
+ COALESCE(ds.active, 0) AS active,
3880
+ COALESCE(ds.errored, 0) AS errored,
3881
+ COALESCE(ds.chunked, 0) AS chunked,
3882
+ COALESCE(ch.chunk_count, 0) AS chunk_count,
3883
+ COALESCE(ch.embedded_count, 0) AS embedded_count
3834
3884
  FROM collections c
3835
- LEFT JOIN documents d ON d.collection = c.name
3836
- GROUP BY c.name, c.path
3885
+ LEFT JOIN document_stats ds ON ds.collection = c.name
3886
+ LEFT JOIN collection_chunks ch ON ch.collection = c.name
3887
+ ORDER BY c.name
3837
3888
  `
3838
3889
  )
3839
3890
  .all(embedModel, embedModel, embedFingerprint);
@@ -3865,21 +3916,27 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3865
3916
  [string | null, string | null, string | null]
3866
3917
  >(
3867
3918
  `
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
3919
+ WITH active_mirrors AS (
3920
+ SELECT DISTINCT mirror_hash
3921
+ FROM documents
3922
+ WHERE active = 1 AND mirror_hash IS NOT NULL
3923
+ ),
3924
+ matching_vectors AS (
3925
+ SELECT mirror_hash, seq, MAX(embedded_at) AS embedded_at
3926
+ FROM content_vectors
3927
+ WHERE (? IS NULL OR (
3928
+ model = ? AND embed_fingerprint = ?
3929
+ ))
3930
+ GROUP BY mirror_hash, seq
3882
3931
  )
3932
+ SELECT COUNT(*) AS count
3933
+ FROM active_mirrors am
3934
+ JOIN content_chunks c ON c.mirror_hash = am.mirror_hash
3935
+ LEFT JOIN matching_vectors mv
3936
+ ON mv.mirror_hash = c.mirror_hash
3937
+ AND mv.seq = c.seq
3938
+ AND mv.embedded_at >= c.created_at
3939
+ WHERE mv.mirror_hash IS NULL
3883
3940
  `
3884
3941
  )
3885
3942
  .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
+ }
@@ -800,6 +800,12 @@ export interface StorePort {
800
800
  */
801
801
  syncContexts(contexts: Context[]): Promise<StoreResult<void>>;
802
802
 
803
+ /**
804
+ * Monotonic in-process generation for the persisted context snapshot.
805
+ * Changes after a successful context sync or when the store is reopened.
806
+ */
807
+ getContextGeneration(): number;
808
+
803
809
  /**
804
810
  * Get all collections from DB.
805
811
  */
@@ -1110,7 +1116,9 @@ export interface StorePort {
1110
1116
  /**
1111
1117
  * Rebuild derived semantic edges from currently indexed links.
1112
1118
  */
1113
- backfillDocEdges(): Promise<StoreResult<{ inserted: number }>>;
1119
+ backfillDocEdges(
1120
+ sourceDocumentIds?: number[]
1121
+ ): Promise<StoreResult<{ inserted: number }>>;
1114
1122
 
1115
1123
  // ─────────────────────────────────────────────────────────────────────────
1116
1124
  // Graph