diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 73ca325365..472ddaefe4 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -148,6 +148,8 @@ Scoped `--files` / `--commits` refreshes reuse the same path filter as full scan Incremental refreshes that mutate `fts_chunks` increment `codeindex_meta.fts_incremental_writes_since_optimize`. When the counter reaches `DbWriter.DefaultFtsOptimizeIncrementalWriteThreshold`, the update path runs `INSERT INTO fts_chunks(fts_chunks) VALUES('optimize')`, resets the counter, and stamps `fts_last_optimized_at`. Users can run the same maintenance directly with `cdidx optimize --db ` or `cdidx index --optimize`; this may briefly hold the writer lock on large indexes. +Successful writer sessions attempt `PRAGMA wal_checkpoint(TRUNCATE)` before closing a writable `DbContext`, so large WAL files are reclaimed after index, backfill, optimize, prune, and other DB-writing commands. `cdidx db schema [--json]` dumps `sqlite_master` entries plus `PRAGMA user_version` for schema inspection, and `cdidx db prune --dry-run|--apply [--json]` counts or deletes orphaned `symbol_references`, `reference_lines`, and `symbols` rows before running `PRAGMA optimize` on apply. + ### Extending the indexer Out-of-tree post-extraction hooks can implement `CodeIndex.Indexer.Hooks.IPostExtractionHook` in a `.dll` placed under `~/.config/cdidx/hooks/` (or the directory named by `CDIDX_HOOKS_DIR`). Hook assemblies are discovered in path order. Each concrete hook type is instantiated with a public parameterless constructor, then called after built-in symbol extraction and again after built-in reference extraction, before rows are persisted. Hooks receive a `FileContext` plus mutable `IList` / `IList` values, so they can annotate extracted records, add synthetic symbols, or add domain-specific references. diff --git a/README.md b/README.md index ba52e204ff..b7a4885e35 100644 --- a/README.md +++ b/README.md @@ -376,7 +376,7 @@ upgrade / downgrade 後はインストール済み補完 script を再生成し | MCP 連携 | Claude Code、Cursor、Windsurf などの AI クライアント向け MCP server。tools、インデックス済みファイル resources、starter prompts、ローカル引数検証用の schema constraints、text content block の `mimeType`、logging、構造化された `ping` health result、HTTP `GET /healthz`、opt-in の HTTP `/events` keep-alive notification、stdio または HTTP `/events` stream 上の互換性用 server-side `notifications/initialized` ready signal、`cdidx languages` と同じ言語レジストリ由来の `Language support:` 説明を提供します。Tool schema は未知の引数を `-32602` で拒否し、`x-stability` を公開し、CLI JSON contract と一致する snake_case の structured JSON key を使います。 | | freshness | `--parallelism` による parallel full-scan、`--files` / `--commits` による差分更新、`--watch` による継続更新、`status --check` による完全一致確認、`--stale-after` / `CDIDX_STALE_AFTER` による age threshold 上書きに対応します。 | | storage | `.cdidx/codeindex.db` に保存する local-first 設計。ネストしたディレクトリからの query コマンドは、current directory にフォールバックする前に最上位祖先の `.cdidx/codeindex.db` を優先します。既定の SQLite 保存先は `--data-dir `、`CDIDX_DATA_DIR`、`XDG_DATA_HOME` で workspace 外へ移せます。明示的な `--db ` は引き続き最優先です。 | -| DB maintenance | 新規 index DB は SQLite incremental auto-vacuum を使います。既存 DB は `cdidx vacuum` で free page を回収でき、legacy no-autovacuum DB は初回だけ full `VACUUM` で変換します。`status --json` は `db_pragma_settings` 配下に metrics を出力します。 | +| DB maintenance | 新規 index DB は SQLite incremental auto-vacuum を使います。成功した writer 実行は WAL を `TRUNCATE` checkpoint します。既存 DB は `cdidx vacuum` で free page を回収でき、legacy no-autovacuum DB は初回だけ full `VACUUM` で変換します。`cdidx db schema` は on-disk schema を出力し、`cdidx db prune --dry-run|--apply` は orphaned DB rows を検査・削除します。`status --json` は `db_pragma_settings` 配下に metrics を出力します。 | | security defaults | POSIX では `.cdidx` を `0700` 権限で作成します。`status --json` は利用可能な場合に実効 POSIX mode を `data_dir_mode` として報告します。 | | diagnostics | `status --config` は source attribution 付きの effective configuration を出力し、`status --explain ` は readiness field の意味と対処を説明します。read 系コマンドは `--profile`、`--slow-query-ms `、--trace=stderr|file|none に対応し、file trace は lifecycle log と同じ場所に日次 `query-trace-YYYYMMDD.jsonl` を書きます。 | | drift checks | `cdidx diff ` は schema、file、symbol、reference の差分を比較します。exit code は `0` identical、`1` drift、`2` schema mismatch、`3` unreadable DB です。 | diff --git a/changelog.d/unreleased/1644.fixed.md b/changelog.d/unreleased/1644.fixed.md new file mode 100644 index 0000000000..7be36cb591 --- /dev/null +++ b/changelog.d/unreleased/1644.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1644 +affected: + - src/CodeIndex/Database/DbContext.cs + - tests/CodeIndex.Tests/DatabaseTests.cs +--- + +## English + +- **Successful writer sessions now truncate-checkpoint the SQLite WAL (#1644)** — writable DB contexts attempt `PRAGMA wal_checkpoint(TRUNCATE)` after write work so large `codeindex.db-wal` sidecars are reclaimed after successful maintenance and index runs. + +## 日本語 + +- **成功した writer session が SQLite WAL を truncate checkpoint するようになりました (#1644)** — 書き込みを行った DB context は `PRAGMA wal_checkpoint(TRUNCATE)` を試行し、成功した maintenance / index 実行後に肥大化した `codeindex.db-wal` sidecar を回収します。 diff --git a/changelog.d/unreleased/1646.added.md b/changelog.d/unreleased/1646.added.md new file mode 100644 index 0000000000..c9f6a06774 --- /dev/null +++ b/changelog.d/unreleased/1646.added.md @@ -0,0 +1,17 @@ +--- +category: added +issues: + - 1646 +affected: + - src/CodeIndex/Cli/DbCommandRunner.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - tests/CodeIndex.Tests/DbCommandRunnerTests.cs +--- + +## English + +- **Added `cdidx db schema` for on-disk schema inspection (#1646)** — the new command prints SQLite schema entries and `PRAGMA user_version`, with `--json` for automation. + +## 日本語 + +- **on-disk schema を確認する `cdidx db schema` を追加しました (#1646)** — 新しいコマンドは SQLite schema entries と `PRAGMA user_version` を出力し、自動化向けに `--json` も提供します。 diff --git a/changelog.d/unreleased/1728.added.md b/changelog.d/unreleased/1728.added.md new file mode 100644 index 0000000000..15b9903886 --- /dev/null +++ b/changelog.d/unreleased/1728.added.md @@ -0,0 +1,17 @@ +--- +category: added +issues: + - 1728 +affected: + - src/CodeIndex/Cli/DbCommandRunner.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - tests/CodeIndex.Tests/DbCommandRunnerTests.cs +--- + +## English + +- **Added `cdidx db prune --dry-run|--apply` for stale DB rows (#1728)** — the command counts or deletes orphaned `symbol_references`, `reference_lines`, and `symbols` rows, then runs `PRAGMA optimize` after apply. + +## 日本語 + +- **stale DB rows を処理する `cdidx db prune --dry-run|--apply` を追加しました (#1728)** — このコマンドは orphaned `symbol_references`、`reference_lines`、`symbols` rows を集計または削除し、apply 後に `PRAGMA optimize` を実行します。 diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 2d5017faef..3364b45398 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -85,7 +85,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("workspace", "cdidx workspace [name] [--json]"), ("config", "cdidx config show [--json]"), ("validate-config", "cdidx validate-config"), - ("db", "cdidx db --integrity-check [--db ] [--json]"), + ("db", "cdidx db --integrity-check|schema|prune [--dry-run|--apply] [--db ] [--json]"), ("diff", "cdidx diff [--json] [--summary-only] [--detailed] [--limit ]"), ("report", "cdidx report --output [--db ] [--json] [--log-lines ] [--no-log] [--include-args]"), ("validate", "cdidx validate [--db ] [--json] [--verbose] [--kind ] [--path ]"), @@ -768,6 +768,8 @@ private static void PrintCommandSummary() Console.WriteLine(" upgrade Check for and install the latest release via install.sh"); Console.WriteLine(" validate-config Validate .cdidx/config.json or .cdidxrc.json"); Console.WriteLine(" db --integrity-check Run SQLite `PRAGMA integrity_check` and report findings"); + Console.WriteLine(" db schema Dump SQLite schema entries and PRAGMA user_version"); + Console.WriteLine(" db prune --dry-run|--apply Count or delete orphaned DB rows"); Console.WriteLine(" diff Compare two index databases; exit 0 identical, 1 drift, 2 schema mismatch, 3 unreadable"); Console.WriteLine(" report --output Build a redacted crash-repro tarball (.tgz) for bug reports"); Console.WriteLine(" validate Report encoding issues (U+FFFD, BOM, null bytes, mixed line endings, UTF-16 BOM, likely non-UTF8)"); diff --git a/src/CodeIndex/Cli/DbCommandRunner.cs b/src/CodeIndex/Cli/DbCommandRunner.cs index e1a9e91e8d..3abe0987fc 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.cs @@ -1,16 +1,17 @@ using System.Text.Json; +using CodeIndex.Database; using CodeIndex.Indexer; using Microsoft.Data.Sqlite; namespace CodeIndex.Cli; /// -/// Runs `db` subcommands that operate directly on the SQLite file (integrity check, etc.). -/// SQLite ファイル本体に対する `db` サブコマンド(整合性チェックなど)を実行する。 +/// Runs `db` subcommands that operate directly on the SQLite file (integrity check, schema, prune). +/// SQLite ファイル本体に対する `db` サブコマンド(整合性チェック、schema、prune)を実行する。 /// public static class DbCommandRunner { - public static int RunIntegrityCheck(string[] cmdArgs, JsonSerializerOptions jsonOptions) + public static int Run(string[] cmdArgs, JsonSerializerOptions jsonOptions) { var options = ParseArgs(cmdArgs); if (options.ShowHelp) @@ -28,13 +29,22 @@ public static int RunIntegrityCheck(string[] cmdArgs, JsonSerializerOptions json "Run `cdidx db --integrity-check --help` to see the supported command shape.", CommandErrorCodes.UsageError); - if (!options.IntegrityCheck) + if (!options.IntegrityCheck && !options.Schema && !options.Prune) return WriteCommandError( options.Json, jsonOptions, "db requires a mode flag", CommandExitCodes.UsageError, - "Pass `--integrity-check` to run `PRAGMA integrity_check` on the database.", + "Pass `--integrity-check`, `schema`, or `prune --dry-run|--apply`.", + CommandErrorCodes.UsageError); + + if ((options.IntegrityCheck ? 1 : 0) + (options.Schema ? 1 : 0) + (options.Prune ? 1 : 0) > 1) + return WriteCommandError( + options.Json, + jsonOptions, + "db accepts exactly one mode", + CommandExitCodes.UsageError, + "Run one of `cdidx db --integrity-check`, `cdidx db schema`, or `cdidx db prune --dry-run|--apply`.", CommandErrorCodes.UsageError); var dbPath = options.DbPath; @@ -48,6 +58,19 @@ public static int RunIntegrityCheck(string[] cmdArgs, JsonSerializerOptions json "Point `--db` at an existing `codeindex.db`, or run `cdidx index ` first to create one.", CommandErrorCodes.DbNotFound); + if (options.Schema) + return RunSchema(options, jsonOptions, dbPath, isUri); + + if (options.Prune) + return RunPrune(options, jsonOptions, dbPath, isUri); + + return RunIntegrityCheck(options, jsonOptions, dbPath, isUri); + } + + public static int RunIntegrityCheck(string[] cmdArgs, JsonSerializerOptions jsonOptions) => Run(cmdArgs, jsonOptions); + + private static int RunIntegrityCheck(DbCommandOptions options, JsonSerializerOptions jsonOptions, string dbPath, bool isUri) + { try { var issues = RunIntegrityCheckPragma(dbPath); @@ -96,6 +119,124 @@ public static int RunIntegrityCheck(string[] cmdArgs, JsonSerializerOptions json } } + private static int RunSchema(DbCommandOptions options, JsonSerializerOptions jsonOptions, string dbPath, bool isUri) + { + try + { + var schema = ReadSchema(dbPath); + var fullPath = Path.GetFullPath(isUri ? dbPath : dbPath); + if (options.Json) + { + var jsonContext = CliJsonSerializerContextFactory.Create(jsonOptions); + Console.WriteLine(JsonSerializer.Serialize( + new DbSchemaJsonResult(fullPath, schema.UserVersion, schema.Entries), + jsonContext.DbSchemaJsonResult)); + } + else + { + Console.WriteLine("Database schema"); + Console.WriteLine($" database : {fullPath}"); + Console.WriteLine($" user_version: {schema.UserVersion}"); + foreach (var entry in schema.Entries) + { + Console.WriteLine(); + Console.WriteLine($"-- {entry.Type}: {entry.Name}"); + if (!string.IsNullOrWhiteSpace(entry.Sql)) + Console.WriteLine(entry.Sql); + } + } + + return CommandExitCodes.Success; + } + catch (Exception ex) + { + if (JsonOutputFailure.TryHandle(ex, out var exitCode)) + return exitCode; + + return WriteCommandError( + options.Json, + jsonOptions, + $"failed to read schema: {ex.Message}", + CommandExitCodes.DatabaseError, + "Retry `cdidx db schema`. If this persists, rebuild with `cdidx index --rebuild`.", + CommandErrorCodes.DbError); + } + } + + private static int RunPrune(DbCommandOptions options, JsonSerializerOptions jsonOptions, string dbPath, bool isUri) + { + if (!options.PruneApply && !options.PruneDryRun) + return WriteCommandError( + options.Json, + jsonOptions, + "db prune requires --dry-run or --apply", + CommandExitCodes.UsageError, + "Use `cdidx db prune --dry-run` to inspect stale rows, then `cdidx db prune --apply` to delete them.", + CommandErrorCodes.UsageError); + + if (options.PruneApply && options.PruneDryRun) + return WriteCommandError( + options.Json, + jsonOptions, + "db prune accepts only one of --dry-run or --apply", + CommandExitCodes.UsageError, + "Choose `--dry-run` or `--apply`.", + CommandErrorCodes.UsageError); + + if (isUri && DbPathResolver.UriRequestsReadOnly(dbPath)) + return WriteCommandError( + options.Json, + jsonOptions, + $"database must be writable for prune: {dbPath}", + CommandExitCodes.DatabaseError, + "Point `--db` at a writable filesystem path, or omit read-only URI parameters such as `immutable=1` / `mode=ro`.", + CommandErrorCodes.DbNotWritable); + + try + { + var result = PruneOrphans(dbPath, apply: options.PruneApply); + var fullPath = Path.GetFullPath(isUri ? dbPath : dbPath); + if (options.Json) + { + var jsonContext = CliJsonSerializerContextFactory.Create(jsonOptions); + Console.WriteLine(JsonSerializer.Serialize( + new DbPruneJsonResult( + "success", + fullPath, + options.PruneDryRun, + result.OrphanSymbolReferences, + result.OrphanReferenceLines, + result.OrphanSymbols, + result.Total), + jsonContext.DbPruneJsonResult)); + } + else + { + Console.WriteLine(options.PruneApply ? "Pruned database stale rows." : "Database prune dry run."); + Console.WriteLine($" database : {fullPath}"); + Console.WriteLine($" orphan symbol_references : {result.OrphanSymbolReferences:N0}"); + Console.WriteLine($" orphan reference_lines : {result.OrphanReferenceLines:N0}"); + Console.WriteLine($" orphan symbols : {result.OrphanSymbols:N0}"); + Console.WriteLine($" total : {result.Total:N0}"); + } + + return CommandExitCodes.Success; + } + catch (Exception ex) + { + if (JsonOutputFailure.TryHandle(ex, out var exitCode)) + return exitCode; + + return WriteCommandError( + options.Json, + jsonOptions, + $"failed to prune database: {ex.Message}", + CommandExitCodes.DatabaseError, + "Ensure no other writer is holding the database lock, then retry `cdidx db prune --dry-run`.", + CommandErrorCodes.DbError); + } + } + // PRAGMA integrity_check returns a single row `"ok"` when the file passes every consistency // probe, otherwise it returns up to N rows of corruption findings. The pragma itself only // reads the database, so a read-only connection is sufficient and avoids the WAL-mode @@ -126,11 +267,133 @@ private static List RunIntegrityCheckPragma(string dbPath) return rows.Count > 0 ? rows : new List { "ok" }; } + private static (int UserVersion, List Entries) ReadSchema(string dbPath) + { + using var connection = OpenConnection(dbPath, writable: false); + using var versionCmd = connection.CreateCommand(); + versionCmd.CommandText = "PRAGMA user_version"; + var rawVersion = versionCmd.ExecuteScalar(); + var userVersion = rawVersion is long l ? (int)l : (rawVersion is int i ? i : 0); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" + SELECT type, name, tbl_name, sql + FROM sqlite_master + WHERE type IN ('table', 'index', 'trigger', 'view') + ORDER BY type, name"; + using var reader = cmd.ExecuteReader(); + var entries = new List(); + while (reader.Read()) + { + entries.Add(new DbSchemaEntryJsonResult( + reader.GetString(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetString(3))); + } + + return (userVersion, entries); + } + + private static (int OrphanSymbolReferences, int OrphanReferenceLines, int OrphanSymbols, int Total) PruneOrphans(string dbPath, bool apply) + { + using var connection = OpenConnection(dbPath, writable: apply); + using var transaction = apply ? connection.BeginTransaction() : null; + + var orphanSymbolReferences = Count(connection, transaction, @" + SELECT COUNT(*) + FROM symbol_references sr + LEFT JOIN files f ON f.id = sr.file_id + LEFT JOIN reference_lines rl ON rl.id = sr.reference_line_id + LEFT JOIN files rlf ON rlf.id = rl.file_id + WHERE f.id IS NULL + OR (sr.reference_line_id IS NOT NULL AND (rl.id IS NULL OR rlf.id IS NULL))"); + var orphanReferenceLines = Count(connection, transaction, @" + SELECT COUNT(*) + FROM reference_lines rl + LEFT JOIN files f ON f.id = rl.file_id + WHERE f.id IS NULL"); + var orphanSymbols = Count(connection, transaction, @" + SELECT COUNT(*) + FROM symbols s + LEFT JOIN files f ON f.id = s.file_id + WHERE f.id IS NULL"); + + if (apply) + { + Execute(connection, transaction, @" + DELETE FROM symbol_references + WHERE file_id NOT IN (SELECT id FROM files) + OR (reference_line_id IS NOT NULL AND reference_line_id NOT IN ( + SELECT rl.id + FROM reference_lines rl + INNER JOIN files f ON f.id = rl.file_id + ))"); + Execute(connection, transaction, "DELETE FROM reference_lines WHERE file_id NOT IN (SELECT id FROM files)"); + Execute(connection, transaction, "DELETE FROM symbols WHERE file_id NOT IN (SELECT id FROM files)"); + transaction!.Commit(); + Execute(connection, null, "PRAGMA optimize"); + RunWalCheckpointTruncate(connection); + } + + var total = orphanSymbolReferences + orphanReferenceLines + orphanSymbols; + return (orphanSymbolReferences, orphanReferenceLines, orphanSymbols, total); + } + + private static SqliteConnection OpenConnection(string dbPath, bool writable) + { + var connectionString = dbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase) + ? $"Data Source={dbPath}" + : new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = writable ? SqliteOpenMode.ReadWrite : SqliteOpenMode.ReadOnly, + }.ConnectionString; + var connection = new SqliteConnection(connectionString); + connection.Open(); + return connection; + } + + private static int Count(SqliteConnection connection, SqliteTransaction? transaction, string sql) + { + using var cmd = connection.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = sql; + return Convert.ToInt32(cmd.ExecuteScalar(), System.Globalization.CultureInfo.InvariantCulture); + } + + private static void Execute(SqliteConnection connection, SqliteTransaction? transaction, string sql) + { + using var cmd = connection.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + + private static void RunWalCheckpointTruncate(SqliteConnection connection) + { + try + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)"; + DbContext.WalCheckpointTruncateExecutedForTesting?.Invoke(connection.DataSource); + cmd.ExecuteNonQuery(); + } + catch (Exception) + { + // WAL truncation is opportunistic cleanup. Prune has already committed. + } + } + internal static DbCommandOptions ParseArgs(string[] args) { var dbPath = Path.Combine(".cdidx", "codeindex.db"); var json = false; var integrityCheck = false; + var schema = false; + var prune = false; + var pruneDryRun = false; + var pruneApply = false; string? parseError = null; for (var i = 0; i < args.Length; i++) @@ -149,6 +412,18 @@ internal static DbCommandOptions ParseArgs(string[] args) case "--integrity-check": integrityCheck = true; break; + case "schema": + schema = true; + break; + case "prune": + prune = true; + break; + case "--dry-run": + pruneDryRun = true; + break; + case "--apply": + pruneApply = true; + break; case "--help" or "-h": return new DbCommandOptions { ShowHelp = true, DbPath = dbPath, Json = json }; default: @@ -168,6 +443,10 @@ internal static DbCommandOptions ParseArgs(string[] args) DbPath = dbPath, Json = json, IntegrityCheck = integrityCheck, + Schema = schema, + Prune = prune, + PruneDryRun = pruneDryRun, + PruneApply = pruneApply, ParseError = parseError, }; } @@ -195,5 +474,9 @@ internal sealed class DbCommandOptions public bool Json { get; init; } public bool ShowHelp { get; init; } public bool IntegrityCheck { get; init; } + public bool Schema { get; init; } + public bool Prune { get; init; } + public bool PruneDryRun { get; init; } + public bool PruneApply { get; init; } public string? ParseError { get; init; } } diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 52efc6dbbf..87e05fd567 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -43,6 +43,26 @@ internal sealed record DbIntegrityCheckJsonResult( [property: JsonPropertyName("ok")] bool Ok, [property: JsonPropertyName("issues")] List Issues); +internal sealed record DbSchemaEntryJsonResult( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("table_name")] string? TableName, + [property: JsonPropertyName("sql")] string? Sql); + +internal sealed record DbSchemaJsonResult( + [property: JsonPropertyName("db_path")] string DbPath, + [property: JsonPropertyName("user_version")] int UserVersion, + [property: JsonPropertyName("entries")] List Entries); + +internal sealed record DbPruneJsonResult( + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("db_path")] string DbPath, + [property: JsonPropertyName("dry_run")] bool DryRun, + [property: JsonPropertyName("orphan_symbol_references")] int OrphanSymbolReferences, + [property: JsonPropertyName("orphan_reference_lines")] int OrphanReferenceLines, + [property: JsonPropertyName("orphan_symbols")] int OrphanSymbols, + [property: JsonPropertyName("total")] int Total); + internal sealed record DiffSummaryJsonResult( [property: JsonPropertyName("left_file_count")] long LeftFileCount, [property: JsonPropertyName("right_file_count")] long RightFileCount, @@ -307,6 +327,9 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(CommandErrorJsonResult))] [JsonSerializable(typeof(ConfigShowJsonResult))] [JsonSerializable(typeof(DbIntegrityCheckJsonResult))] +[JsonSerializable(typeof(DbPruneJsonResult))] +[JsonSerializable(typeof(DbSchemaEntryJsonResult))] +[JsonSerializable(typeof(DbSchemaJsonResult))] [JsonSerializable(typeof(DefinitionResult))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Dictionary))] diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index a67e29742f..c12f62b495 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -263,7 +263,7 @@ internal static int Run( CommandExitCodes.UsageError, "use `cdidx config show`."), "workspace" => WorkspaceCommandRunner.Run(subArgs, jsonOptions), - "db" => DbCommandRunner.RunIntegrityCheck(subArgs, jsonOptions), + "db" => DbCommandRunner.Run(subArgs, jsonOptions), "report" => ReportCommandRunner.Run(subArgs, jsonOptions, appVersion), "test-extractor" => RunTestExtractor(subArgs, jsonOptions), _ when IsProjectPathArg(commandName) diff --git a/src/CodeIndex/Database/DbContext.cs b/src/CodeIndex/Database/DbContext.cs index 050cd2c0b9..23dbdce436 100644 --- a/src/CodeIndex/Database/DbContext.cs +++ b/src/CodeIndex/Database/DbContext.cs @@ -40,10 +40,12 @@ public class DbContext : IDisposable private PreparedCommandCache? _preparedCommands; private bool _suppressWriteWorkTracking = true; private bool _hasWriteWork; + private bool _hasWalCheckpointableWriteWork; private bool _rebuildFtsAfterSchemaMigration; internal static Action? OptimizePragmaExecutedForTesting { get; set; } internal static Action? PlannerStatisticsCommandExecutedForTesting { get; set; } + internal static Action? WalCheckpointTruncateExecutedForTesting { get; set; } public SqliteConnection Connection => _connection; public bool IsReadOnly => _isReadOnly; @@ -362,6 +364,28 @@ private static bool TryCheckpointWalBeforeReadOnlyFallback(string dbPath) } } + public bool TryCheckpointWalTruncate() + { + if (_isReadOnly) + return false; + + _walCheckpointAttempted = true; + try + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)"; + WalCheckpointTruncateExecutedForTesting?.Invoke(_connection.DataSource); + cmd.ExecuteNonQuery(); + _walCheckpointSucceeded = true; + return true; + } + catch (Exception) + { + _walCheckpointSucceeded = false; + return false; + } + } + public static string ToReadOnlyUri(string dbPath) { if (dbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) @@ -2116,7 +2140,7 @@ private void Execute(string sql) cmd.Transaction = _activeMigrationTransaction; cmd.CommandText = sql; cmd.ExecuteNonQuery(); - MarkWriteWork(); + MarkWriteWork(walCheckpointable: false); } private void EnsureForeignKeysEnabled() @@ -2500,10 +2524,14 @@ INSERT INTO codeindex_meta (key, value) VALUES ('codeindex_meta_schema_version', stamp.ExecuteNonQuery(); } - internal void MarkWriteWork() + internal void MarkWriteWork(bool walCheckpointable = true) { if (!_isReadOnly && !_suppressWriteWorkTracking) + { _hasWriteWork = true; + if (walCheckpointable) + _hasWalCheckpointableWriteWork = true; + } } internal void RunPlannerStatisticsMaintenance(bool forceAnalyze) @@ -2545,7 +2573,11 @@ public void Dispose() // connection teardown の競合を防ぐ。 _preparedCommands?.Dispose(); _preparedCommands = null; + var hadWriteWork = _hasWriteWork; + var hadWalCheckpointableWriteWork = _hasWalCheckpointableWriteWork; RunOptimizeOnCloseIfNeeded(); + if (hadWalCheckpointableWriteWork) + TryCheckpointWalTruncate(); _connection.Dispose(); } } diff --git a/src/CodeIndex/Database/DbWriter.cs b/src/CodeIndex/Database/DbWriter.cs index 1ac42ff703..15f90e05d6 100644 --- a/src/CodeIndex/Database/DbWriter.cs +++ b/src/CodeIndex/Database/DbWriter.cs @@ -71,7 +71,7 @@ public DbWriter(DbContext context) : this( (context ?? throw new ArgumentNullException(nameof(context))).Connection, context.IsReadOnly ? null : context.PreparedCommands, - context.MarkWriteWork) + () => context.MarkWriteWork()) { } diff --git a/tests/CodeIndex.Tests/DatabaseTests.cs b/tests/CodeIndex.Tests/DatabaseTests.cs index db42de811d..458a754b2b 100644 --- a/tests/CodeIndex.Tests/DatabaseTests.cs +++ b/tests/CodeIndex.Tests/DatabaseTests.cs @@ -568,6 +568,65 @@ public void OptimizeFts_ResetsIncrementalWriteCounterAndStampsTime() Assert.False(string.IsNullOrWhiteSpace(_db.GetMetaString(DbWriter.FtsLastOptimizedAtMetaKey))); } + [Fact] + public void TryCheckpointWalTruncate_OnWritableDb_ReportsAttemptAndSuccess() + { + var result = _db.TryCheckpointWalTruncate(); + + Assert.True(result); + Assert.True(_db.WalCheckpointAttempted); + Assert.True(_db.WalCheckpointSucceeded); + } + + [Fact] + public void Dispose_AfterWriteWork_AttemptsWalCheckpoint() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_checkpoint_{Guid.NewGuid():N}.db"); + var checkpointAttempted = false; + DbContext.WalCheckpointTruncateExecutedForTesting = _ => checkpointAttempted = true; + try + { + using (var db = new DbContext(dbPath)) + { + db.InitializeSchema(); + db.MarkWriteWork(); + } + + Assert.True(checkpointAttempted); + } + finally + { + DbContext.WalCheckpointTruncateExecutedForTesting = null; + SqliteConnection.ClearAllPools(); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + + [Fact] + public void Dispose_AfterSchemaInitializationOnly_DoesNotCheckpointWal() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_schema_checkpoint_{Guid.NewGuid():N}.db"); + var checkpointAttempted = false; + DbContext.WalCheckpointTruncateExecutedForTesting = _ => checkpointAttempted = true; + try + { + using (var db = new DbContext(dbPath)) + { + db.InitializeSchema(); + } + + Assert.False(checkpointAttempted); + } + finally + { + DbContext.WalCheckpointTruncateExecutedForTesting = null; + SqliteConnection.ClearAllPools(); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + private long UpsertTestFile(string path, string checksum) => _writer.UpsertFile(new FileRecord { diff --git a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs index 3c8f6faec9..e8bf2c0f80 100644 --- a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs @@ -26,6 +26,25 @@ public void ParseArgs_IntegrityCheckFlagSetsFlag() Assert.Null(options.ParseError); } + [Fact] + public void ParseArgs_SchemaSubcommandSetsFlag() + { + var options = DbCommandRunner.ParseArgs(["schema"]); + + Assert.True(options.Schema); + Assert.Null(options.ParseError); + } + + [Fact] + public void ParseArgs_PruneSubcommandSetsApplyFlag() + { + var options = DbCommandRunner.ParseArgs(["prune", "--apply"]); + + Assert.True(options.Prune); + Assert.True(options.PruneApply); + Assert.Null(options.ParseError); + } + [Fact] public void ParseArgs_HelpFlagSetsShowHelp() { @@ -145,6 +164,70 @@ public void Run_CleanDb_JsonReportsOkTrueAndEmptyIssues() } } + [Fact] + public void Run_Schema_JsonIncludesTablesAndUserVersion() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_db_schema_{Guid.NewGuid():N}.db"); + try + { + using (var db = new DbContext(dbPath)) + db.InitializeSchema(); + SqliteConnection.ClearAllPools(); + + var (exitCode, json) = RunAndCaptureJson(["schema", "--db", dbPath, "--json"]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(Path.GetFullPath(dbPath), json.GetProperty("db_path").GetString()); + Assert.True(json.TryGetProperty("user_version", out _)); + Assert.Contains(json.GetProperty("entries").EnumerateArray(), entry => + entry.GetProperty("type").GetString() == "table" && + entry.GetProperty("name").GetString() == "files"); + } + finally + { + SqliteConnection.ClearAllPools(); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + + [Fact] + public void Run_Prune_DryRunCountsAndApplyDeletesOrphans() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_db_prune_{Guid.NewGuid():N}.db"); + try + { + using (var db = new DbContext(dbPath)) + db.InitializeSchema(); + SeedOrphans(dbPath); + SqliteConnection.ClearAllPools(); + + var (dryRunExit, dryRunJson) = RunAndCaptureJson(["prune", "--dry-run", "--db", dbPath, "--json"]); + Assert.Equal(CommandExitCodes.Success, dryRunExit); + Assert.True(dryRunJson.GetProperty("dry_run").GetBoolean()); + Assert.Equal(4, dryRunJson.GetProperty("total").GetInt32()); + + var checkpointAttempted = false; + DbContext.WalCheckpointTruncateExecutedForTesting = _ => checkpointAttempted = true; + var (applyExit, applyJson) = RunAndCaptureJson(["prune", "--apply", "--db", dbPath, "--json"]); + Assert.Equal(CommandExitCodes.Success, applyExit); + Assert.False(applyJson.GetProperty("dry_run").GetBoolean()); + Assert.Equal(4, applyJson.GetProperty("total").GetInt32()); + Assert.True(checkpointAttempted); + + var (secondExit, secondJson) = RunAndCaptureJson(["prune", "--dry-run", "--db", dbPath, "--json"]); + Assert.Equal(CommandExitCodes.Success, secondExit); + Assert.Equal(0, secondJson.GetProperty("total").GetInt32()); + } + finally + { + DbContext.WalCheckpointTruncateExecutedForTesting = null; + SqliteConnection.ClearAllPools(); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + [Fact] public void Run_CorruptedDb_ReturnsDatabaseError() { @@ -191,4 +274,32 @@ public void Run_CorruptedDb_ReturnsDatabaseError() using var document = JsonDocument.Parse(capture.Out!.ToString()!); return (exitCode, document.RootElement.Clone()); } + + private static void SeedOrphans(string dbPath) + { + using var connection = new SqliteConnection(new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadWrite, + }.ConnectionString); + connection.Open(); + using (var pragma = connection.CreateCommand()) + { + pragma.CommandText = "PRAGMA foreign_keys=OFF"; + pragma.ExecuteNonQuery(); + } + + Execute(connection, "INSERT INTO symbols(file_id, kind, name, line) VALUES (9001, 'function', 'Orphan', 1)"); + Execute(connection, "INSERT INTO reference_lines(file_id, line, context) VALUES (9002, 1, 'missing file')"); + Execute(connection, "INSERT INTO symbol_references(file_id, symbol_name, reference_kind, reference_line_id) VALUES (9003, 'Orphan', 'call', 9004)"); + Execute(connection, "INSERT INTO files(id, path, lang, size, lines, modified, checksum) VALUES (1, 'src/live.cs', 'csharp', 1, 1, '2026-01-01T00:00:00Z', 'live')"); + Execute(connection, "INSERT INTO symbol_references(file_id, symbol_name, reference_kind, reference_line_id) VALUES (1, 'Live', 'call', 1)"); + } + + private static void Execute(SqliteConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } }