diff --git a/README.md b/README.md index 9c82c18ae..d7a087d17 100644 --- a/README.md +++ b/README.md @@ -671,7 +671,7 @@ JSON arguments can also be piped on stdin, for tools that take arguments. A tool |------|-------------| | `index_repository` | Index a repository into the graph. Auto-sync keeps it fresh after that. | | `list_projects` | List all indexed projects with node/edge counts. | -| `delete_project` | Remove a project and all its graph data. | +| `delete_project` | Remove a project and all its graph data, including its ADR sidecar (`.adr.db`). | | `index_status` | Check indexing status of a project. | | `check_index_coverage` | Check whether exact paths or a scope are indexed and fresh. A clean result means no recorded gap, not proof of completeness. | diff --git a/scripts/memory-core-baseline.txt b/scripts/memory-core-baseline.txt index ee54b498b..c7160bbff 100644 --- a/scripts/memory-core-baseline.txt +++ b/scripts/memory-core-baseline.txt @@ -77,9 +77,9 @@ src/pipeline/pass_semantic.c 3 src/pipeline/pass_tests.c 3 src/pipeline/pass_usages.c 4 src/pipeline/path_alias.c 42 -src/pipeline/pipeline.c 134 +src/pipeline/pipeline.c 131 src/pipeline/pipeline_delta.c 19 -src/pipeline/pipeline_incremental.c 144 +src/pipeline/pipeline_incremental.c 134 src/pipeline/registry.c 17 src/pipeline/worker_pool.c 2 src/store/store.c 358 diff --git a/src/cli/cli.c b/src/cli/cli.c index b868fdca2..8571782b9 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -7193,6 +7193,13 @@ static const char *get_cache_dir(const char *home_dir) { return cbm_resolve_cache_dir(); } +/* A per-project ADR sidecar (".adr.db") sits in the cache beside its index + * but is not itself an index — exclude it from listing/counting. */ +static bool cli_is_adr_sidecar(const char *name, size_t len) { + const size_t ext = sizeof(".adr.db") - 1; + return len >= ext && strcmp(name + len - ext, ".adr.db") == 0; +} + int cbm_list_indexes(const char *home_dir) { const char *cache_dir = get_cache_dir(home_dir); if (!cache_dir) { @@ -7208,7 +7215,8 @@ int cbm_list_indexes(const char *home_dir) { cbm_dirent_t *ent; while ((ent = cbm_readdir(d)) != NULL) { size_t len = strlen(ent->name); - if (len > DB_EXT_LEN && strcmp(ent->name + len - DB_EXT_LEN, ".db") == 0) { + if (len > DB_EXT_LEN && strcmp(ent->name + len - DB_EXT_LEN, ".db") == 0 && + !cli_is_adr_sidecar(ent->name, len)) { printf(" %s/%s\n", cache_dir, ent->name); count++; } @@ -10437,7 +10445,8 @@ static int count_db_indexes(const char *home) { cbm_dirent_t *ent; while ((ent = cbm_readdir(d)) != NULL) { size_t len = strlen(ent->name); - if (len > DB_EXT_LEN && strcmp(ent->name + len - DB_EXT_LEN, ".db") == 0) { + if (len > DB_EXT_LEN && strcmp(ent->name + len - DB_EXT_LEN, ".db") == 0 && + !cli_is_adr_sidecar(ent->name, len)) { count++; } } diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index d6c7bd468..5523886e8 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -671,7 +671,7 @@ static const tool_def_t TOOLS[] = { "\"offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0}," "\"metadata_only\":{\"type\":\"boolean\",\"default\":false," "\"description\":\"Compatibility: omit counts, size, and branch.\"}}}"}, - {"delete_project", "Delete a project from the index", + {"delete_project", "Delete a project from the index, including its ADR store", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":[" "\"project\"]}"}, @@ -2623,6 +2623,11 @@ static bool is_project_db_file(const char *name, size_t len) { if (len < MCP_MIN_DB_NAME || strcmp(name + len - MCP_DB_EXT, ".db") != 0) { return false; } + /* A per-project ADR sidecar (".adr.db") ends in ".db" but is not itself + * an index — skip it in every cache/cross-repo scan (matches the CLI). */ + if (len >= SLEN(".adr.db") && strcmp(name + len - SLEN(".adr.db"), ".adr.db") == 0) { + return false; + } if (strncmp(name, "_", SLEN("_")) == 0 || strncmp(name, ":memory:", SLEN(":memory:")) == 0) { return false; } @@ -6809,7 +6814,9 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { return result; } -/* delete_project: just erase the .db file (and WAL/SHM). */ +/* delete_project: erase the .db file (and WAL/SHM) plus the ADR sidecar + * ".adr.db" (and its WAL/SHM/journal), so deleting a project removes its + * ADR store with it. */ static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) { char *name = get_project_arg(args); if (!name) { @@ -6848,18 +6855,37 @@ static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) { const char *error_detail = NULL; bool is_error = false; + /* ADR sidecar lives beside the graph DB as ".adr.db" — remove it (and + * its own WAL/SHM/journal) whenever the project is deleted. Sized as the + * graph path plus the ".adr.db" suffix. */ + char adr_path[CBM_SZ_1K + sizeof(".adr.db")]; + int adr_n = snprintf(adr_path, sizeof(adr_path), "%s.adr.db", path); + bool have_adr_path = adr_n > 0 && (size_t)adr_n < sizeof(adr_path); + if (exists) { int rc = cbm_unlink(path); (void)cbm_unlink(wal); (void)cbm_unlink(shm); if (rc == 0) { status = "deleted"; + /* Only drop the ADR store once the graph DB is actually gone, so a + * failed graph delete leaves the project (and its ADR) intact. */ + if (have_adr_path) { + (void)cbm_unlink(adr_path); + (void)cbm_remove_db_sidecars(adr_path); + } } else { status = "delete_failed"; error_detail = strerror(errno); is_error = true; } } else { + /* No graph DB, but a sidecar could still linger (e.g. ADR written then + * the index deleted out of band) — best-effort clean it up. */ + if (have_adr_path) { + (void)cbm_unlink(adr_path); + (void)cbm_remove_db_sidecars(adr_path); + } is_error = true; } @@ -17173,9 +17199,10 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { } } - /* ADRs are stored in the SQLite store (project_summaries), the SAME - * backend the UI /api/adr endpoints use — so writes via the MCP tool and - * the UI are visible to each other (#256). */ + /* ADRs are stored in the per-project ADR sidecar DB (".adr.db"), the + * SAME backend the UI /api/adr endpoints use (via cbm_store_adr_*) — so + * writes via the MCP tool and the UI are visible to each other (#256), and + * a reindex of the graph DB never disturbs them. */ store_recovery_status_t recovery_status = STORE_RECOVERY_NONE; cbm_store_t *resolved = resolve_store_internal(srv, project, mutation_held, !write_request, &recovery_status, true); diff --git a/src/pipeline/artifact.c b/src/pipeline/artifact.c index 2dd368c28..21973953f 100644 --- a/src/pipeline/artifact.c +++ b/src/pipeline/artifact.c @@ -888,13 +888,54 @@ static void artifact_snapshot_tmp_close(artifact_snapshot_tmp_t *tmp) { tmp->dir[0] = '\0'; } +/* Make the snapshot's project_summaries carry exactly the current ADR — and + * nothing stale. ADRs now live in the ".adr.db" sidecar, but a legacy + * pre-sidecar row can still linger in the graph DB and gets copied into the + * snapshot by VACUUM INTO; incremental/closure-repair clones keep carrying it. + * So ALWAYS clear project_summaries first, then insert only when an ADR is + * present (`adr` is NULL when the sidecar has none). Otherwise a no-ADR export + * would ship the stale row and a teammate's import would restore a decision + * record that was deleted. Best-effort: on any failure the artifact simply + * omits the ADR, as before the sidecar existed. */ +static void snapshot_inject_adr(const char *snapshot_path, const cbm_adr_t *adr) { + sqlite3 *db = NULL; + if (sqlite3_open_v2(snapshot_path, &db, SQLITE_OPEN_READWRITE, NULL) != SQLITE_OK) { + sqlite3_close(db); + return; + } + sqlite3_exec(db, + "CREATE TABLE IF NOT EXISTS project_summaries (" + " project TEXT PRIMARY KEY, summary TEXT NOT NULL, source_hash TEXT NOT NULL," + " created_at TEXT NOT NULL, updated_at TEXT NOT NULL);", + NULL, NULL, NULL); + /* Drop any stale row (a deleted ADR must not resurrect through the export). */ + sqlite3_exec(db, "DELETE FROM project_summaries;", NULL, NULL, NULL); + if (adr && adr->project && adr->content) { + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, + "INSERT INTO project_summaries " + "(project, summary, source_hash, created_at, updated_at) " + "VALUES (?1, ?2, '', ?3, ?4);", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, adr->project, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, adr->content, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, adr->created_at ? adr->created_at : "", -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, adr->updated_at ? adr->updated_at : "", -1, SQLITE_TRANSIENT); + sqlite3_step(st); + sqlite3_finalize(st); + } + } + sqlite3_close(db); +} + /* Prepare a stripped DB copy for best-quality export. - * VACUUM INTO → (optionally) drop indexes → VACUUM. Returns malloc'd buffer - * or NULL. VACUUM INTO runs on BOTH quality levels: it is the consistent - * snapshot — the store runs in WAL mode, so raw main-file bytes miss - * committed transactions still in the -wal and can be mid-checkpoint torn - * (#895). Only the index-stripping is BEST-only. */ -static char *prepare_snapshot_db(const char *db_path, size_t *out_size, bool strip_indexes) { + * VACUUM INTO → (optionally) drop indexes → VACUUM → (optionally) inject the + * ADR. Returns malloc'd buffer or NULL. VACUUM INTO runs on BOTH quality + * levels: it is the consistent snapshot — the store runs in WAL mode, so raw + * main-file bytes miss committed transactions still in the -wal and can be + * mid-checkpoint torn (#895). Only the index-stripping is BEST-only. */ +static char *prepare_snapshot_db(const char *db_path, size_t *out_size, bool strip_indexes, + const cbm_adr_t *adr) { artifact_snapshot_tmp_t tmp; if (!artifact_snapshot_tmp_open(&tmp)) { artifact_export_fail("prepare_snapshot_dir", cbm_tmpdir(), "private_tmpdir_failed", errno); @@ -939,6 +980,11 @@ static char *prepare_snapshot_db(const char *db_path, size_t *out_size, bool str } } + /* Carry the ADR (from the sidecar) into the snapshot so the exported + * artifact ships the team's decision record. After the strip/VACUUM, so the + * row is not dropped. */ + snapshot_inject_adr(tmp_path, adr); + /* Reopened by path rather than held on a descriptor across VACUUM INTO, * because sqlite owns the create. The private directory is what makes that * safe: an attacker who cannot enter it cannot swap the file underneath. */ @@ -984,15 +1030,28 @@ int cbm_artifact_export(const char *db_path, const char *repo_path, const char * char *db_data = NULL; int compression_level = ART_ZSTD_FAST; + /* Fetch the ADR from the sidecar (read-only; does not create it) so the + * snapshot can carry it — the team-shared artifact used to include the ADR + * via project_summaries, and the sidecar must not silently break that. */ + cbm_adr_t adr = {0}; + bool have_adr = false; + cbm_store_t *adr_src = cbm_store_open_path_query(db_path); + if (adr_src) { + have_adr = cbm_store_adr_get(adr_src, project_name, &adr) == CBM_STORE_OK; + cbm_store_close(adr_src); + } + const cbm_adr_t *adr_arg = have_adr ? &adr : NULL; + if (quality == CBM_ARTIFACT_BEST) { compression_level = ART_ZSTD_BEST; - db_data = prepare_snapshot_db(db_path, &db_size, true); + db_data = prepare_snapshot_db(db_path, &db_size, true, adr_arg); } else { /* FAST keeps zstd-3 and its indexes, but still snapshots via * VACUUM INTO: the raw main-file bytes of a live WAL store are a * torn copy (#895). */ - db_data = prepare_snapshot_db(db_path, &db_size, false); + db_data = prepare_snapshot_db(db_path, &db_size, false, adr_arg); } + cbm_store_adr_free(&adr); if (!db_data || db_size == 0) { free(db_data); @@ -1202,6 +1261,17 @@ int cbm_artifact_import(const char *repo_path, const char *cache_db_path) { cbm_unlink(wal); cbm_unlink(shm); + /* Restore the imported ADR (carried in the graph DB's project_summaries) into + * the per-project sidecar so it arrives with the shared artifact — even for a + * teammate who already indexed the project (a marked sidecar with no ADR), + * which the marker-gated migrate-once would skip. INSERT OR IGNORE means a + * local ADR is never overwritten. Best-effort. */ + cbm_store_t *restore = cbm_store_open_path_existing(cache_db_path); + if (restore) { + (void)cbm_store_adr_restore_from_graph_if_absent(restore); + cbm_store_close(restore); + } + cbm_log_info("artifact.import", "db", cache_db_path, "size_mb", itoa_buf((int)((size_t)dlen / ART_BYTES_PER_MB))); diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index fa62441a1..4195733a6 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -75,7 +75,6 @@ static atomic_int g_pipeline_busy = 0; static atomic_bool g_persist_test_fail_after_stage_dump = false; static atomic_bool g_persist_test_cancel_after_predump = false; static atomic_bool g_persist_test_cancel_after_destination_prepare = false; -static atomic_bool g_persist_test_fail_adr_capture = false; static cbm_pipeline_test_hook_fn g_persist_test_before_final_manifest = NULL; static void *g_persist_test_before_final_manifest_userdata = NULL; static cbm_pipeline_test_hook_fn g_persist_test_after_stage_created = NULL; @@ -93,10 +92,6 @@ void cbm_pipeline_incremental_test_cancel_after_destination_prepare_once(void) { atomic_store(&g_persist_test_cancel_after_destination_prepare, true); } -void cbm_pipeline_incremental_test_fail_adr_capture_once(void) { - atomic_store(&g_persist_test_fail_adr_capture, true); -} - void cbm_pipeline_incremental_test_before_final_manifest_once(cbm_pipeline_test_hook_fn hook, void *userdata) { g_persist_test_before_final_manifest = hook; @@ -152,7 +147,6 @@ void cbm_pipeline_persist_test_reset_faults(void) { atomic_store(&g_persist_test_fail_after_stage_dump, false); atomic_store(&g_persist_test_cancel_after_predump, false); atomic_store(&g_persist_test_cancel_after_destination_prepare, false); - atomic_store(&g_persist_test_fail_adr_capture, false); g_persist_test_before_final_manifest = NULL; g_persist_test_before_final_manifest_userdata = NULL; g_persist_test_after_stage_created = NULL; @@ -239,10 +233,6 @@ struct cbm_pipeline { bool final_existed; bool existing_generation; - /* ADR (project_summaries) captured before a full-reindex DB delete, so it - * can be restored after the rebuild. NULL when no ADR existed. Issue #516. */ - char *saved_adr; - /* Per-file LSP surfaces serialized at the collect_all_defs seam (the only * moment the result cache is alive), persisted by dump_and_persist_hashes * so the closure-repair incremental route can early-cutoff on surface @@ -444,9 +434,6 @@ void cbm_pipeline_free(cbm_pipeline_t *p) { p->file_errors_count = 0; p->file_errors_cap = 0; free(p->branch_qn); - free(p->saved_adr); /* freed here too: error paths can exit before the - * restore in dump_and_persist_hashes runs. Issue #516. */ - p->saved_adr = NULL; cbm_store_free_lsp_surfaces(p->surface_rows, p->surface_row_count); p->surface_rows = NULL; p->surface_row_count = 0; @@ -1716,38 +1703,39 @@ static int run_parallel_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx, return check_cancel(p) ? CBM_NOT_FOUND : 0; } -static int capture_existing_adr(cbm_pipeline_t *p, const char *db_path) { -#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API - if (atomic_exchange(&g_persist_test_fail_adr_capture, false)) { - return CBM_PIPELINE_ABORT_PRESERVE_DB; - } -#endif - cbm_store_t *adr_store = cbm_store_open_path_query(db_path); - if (!adr_store) { - return CBM_PIPELINE_ABORT_PRESERVE_DB; - } - cbm_adr_t existing = {0}; - int adr_rc = cbm_store_adr_get(adr_store, p->project_name, &existing); - if (adr_rc == CBM_STORE_NOT_FOUND) { - cbm_store_close(adr_store); - free(p->saved_adr); - p->saved_adr = NULL; - return 0; - } - if (adr_rc != CBM_STORE_OK || !existing.content) { - cbm_store_adr_free(&existing); - cbm_store_close(adr_store); - return CBM_PIPELINE_ABORT_PRESERVE_DB; +static size_t stage_root_length(const char *path); + +/* Move a legacy ADR row (pre-sidecar, still in the graph DB's project_summaries) + * into the ".adr.db" sidecar BEFORE a reindex unlinks the old generation — + * otherwise the row is lost on an upgrade whose first action is a full/format + * reindex. Read-only on the graph DB (migration writes only the sidecar, on its + * own connection). One-time via the sidecar's marker; a no-op when there is no + * legacy row. Returns true on success (safe to delete), false to preserve. */ +static bool adr_migrate_before_delete(const char *db_path) { + /* `db_path` in routing is a staging copy; the live generation (and the + * sidecar the reader looks for) is keyed to the FINAL path — the staging + * suffix stripped. Migrate against that, so the ADR lands in + * ".adr.db", not a discarded stage's sidecar. */ + size_t root_len = stage_root_length(db_path); + char final_path[4096]; + if (root_len >= sizeof(final_path)) { + return false; } - char *saved = strdup(existing.content); - cbm_store_adr_free(&existing); - cbm_store_close(adr_store); - if (!saved) { - return CBM_PIPELINE_ABORT_PRESERVE_DB; + memcpy(final_path, db_path, root_len); + final_path[root_len] = '\0'; + /* The live final DB must exist to carry a legacy ADR forward; if it does + * not (a first index), there is nothing to migrate — succeed. But + * cbm_store_open_path_query returns NULL on an open *failure* too, not only + * a missing file, so only proceed when the file genuinely does not exist; + * a real open failure must preserve-and-abort rather than publish over a + * legacy ADR it never moved. */ + cbm_store_t *mig = cbm_store_open_path_query(final_path); + if (!mig) { + return !cbm_file_exists(final_path); } - free(p->saved_adr); - p->saved_adr = saved; - return 0; + int rc = cbm_store_adr_migrate_once(mig); + cbm_store_close(mig); + return rc == CBM_STORE_OK; } /* Route an existing generation. Full rebuilds never delete the live DB here: @@ -1796,11 +1784,19 @@ static int try_incremental_or_delete_db(cbm_pipeline_t *p, cbm_file_info_t *file cbm_log_info("pipeline.route", "path", "format_change_reindex", "stored_format", itoa_buf(fmt)); p->format_migration = true; - int adr_rc = capture_existing_adr(p, db_path); + /* Carry a legacy ADR into the sidecar before deleting the old graph DB; + * preserve and abort if that fails, so the decision record is never lost + * to a rebuild. The sidecar itself is untouched by cbm_remove_db_sidecars + * (it strips only -wal/-shm/-journal). */ + if (!adr_migrate_before_delete(db_path)) { + cbm_log_warn("pipeline.route", "reason", "adr_migrate_failed"); + free(db_path); + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } (void)cbm_unlink(db_path); (void)cbm_remove_db_sidecars(db_path); free(db_path); - return adr_rc != 0 ? adr_rc : CBM_PIPELINE_FORCE_FULL_REINDEX; + return CBM_PIPELINE_FORCE_FULL_REINDEX; } cbm_log_info("pipeline.route", "path", "incremental_manifest"); @@ -1813,9 +1809,13 @@ static int try_incremental_or_delete_db(cbm_pipeline_t *p, cbm_file_info_t *file * no-op and successful-incremental routes -- the pipeline reports success * while every later reader finds no store. */ if (rc == CBM_PIPELINE_FORCE_FULL_REINDEX) { - int adr_rc = capture_existing_adr(p, db_path); - if (adr_rc != 0) { - rc = adr_rc; + /* Carry a legacy ADR into the sidecar before deleting the old graph DB; + * preserve and abort if that fails. The sidecar survives the delete + * (cbm_remove_db_sidecars strips only -wal/-shm/-journal). */ + if (!adr_migrate_before_delete(db_path)) { + cbm_log_warn("pipeline.route", "reason", "adr_migrate_failed"); + free(db_path); + return CBM_PIPELINE_ABORT_PRESERVE_DB; } (void)cbm_unlink(db_path); (void)cbm_remove_db_sidecars(db_path); @@ -2273,10 +2273,8 @@ int cbm_pipeline_publish_staged(char *stage_path, const cbm_pipeline_generation_ cbm_store_upsert_lsp_surface_batch(store, generation->surface_rows, generation->surface_row_count) == CBM_STORE_OK; } - if (ok && generation->adr_content) { - ok = cbm_store_adr_store(store, generation->project, generation->adr_content) == - CBM_STORE_OK; - } + /* ADRs are NOT written here: they live in the ".adr.db" sidecar, which + * this rebuild never touches, so there is nothing to re-apply. */ if (ok) { ok = cbm_store_set_format_version(store, CBM_INDEX_FORMAT_VERSION) == CBM_STORE_OK; @@ -2518,7 +2516,6 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas .cancelled = p->cancelled, .manifest = manifest, .manifest_count = manifest_count, - .adr_content = p->saved_adr, .coverage = cov, .coverage_count = cov_count, .coverage_meta = @@ -2561,8 +2558,6 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas cbm_log_warn("index.ignored_capped", "stored", itoa_buf(p->ignored_count), "total", itoa_buf(p->ignored_total)); } - free(p->saved_adr); - p->saved_adr = NULL; free(db_path); return 0; diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 154518658..1f0590a58 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1498,8 +1498,7 @@ static int run_postpasses(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed_file * generation boundary as full indexing. */ static int dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *project, atomic_int *cancelled, const cbm_file_hash_t *manifest, - int manifest_count, const char *adr_content, - const cbm_coverage_row_t *cov, int cov_count, + int manifest_count, const cbm_coverage_row_t *cov, int cov_count, const cbm_coverage_meta_t *meta_template, const cbm_lsp_surface_row_t *surface_rows, int surface_row_count) { struct timespec t; @@ -1511,7 +1510,6 @@ static int dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *p .cancelled = cancelled, .manifest = manifest, .manifest_count = manifest_count, - .adr_content = adr_content, .coverage = cov, .coverage_count = cov_count, .coverage_meta = meta_template ? *meta_template : (cbm_coverage_meta_t){0}, @@ -2328,7 +2326,6 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char .cancelled = cbm_pipeline_cancelled_ptr(p), .manifest = manifest, .manifest_count = manifest_count, - .adr_content = NULL, /* the clone already carries the ADR rows */ .coverage = cov, .coverage_count = cov_n, .coverage_meta = @@ -2628,43 +2625,8 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil return CBM_NOT_FOUND; } - char *saved_adr = NULL; - cbm_adr_t existing_adr = {0}; - int adr_rc = cbm_store_adr_get(store, project, &existing_adr); - if (adr_rc == CBM_STORE_OK) { - bool had_adr_content = existing_adr.content != NULL; - if (had_adr_content) { - saved_adr = strdup(existing_adr.content); - } - cbm_store_adr_free(&existing_adr); - if (had_adr_content && !saved_adr) { - cbm_gbuf_free(existing); - free(changed_files); - for (int i = 0; i < deleted_count; i++) { - free(deleted[i]); - } - free(deleted); - free_mode_skipped(mode_skipped, mode_skipped_count); - cbm_store_free_coverage(old_cov, old_cov_count); - cbm_store_close(store); - closure_plan_free(&closure_plan); - return CBM_PIPELINE_ABORT_PRESERVE_DB; - } - } else if (adr_rc != CBM_STORE_NOT_FOUND) { - cbm_store_adr_free(&existing_adr); - cbm_gbuf_free(existing); - free(changed_files); - for (int i = 0; i < deleted_count; i++) { - free(deleted[i]); - } - free(deleted); - free_mode_skipped(mode_skipped, mode_skipped_count); - cbm_store_free_coverage(old_cov, old_cov_count); - cbm_store_close(store); - closure_plan_free(&closure_plan); - return CBM_PIPELINE_ABORT_PRESERVE_DB; - } - + /* ADRs live in the ".adr.db" sidecar, which the delta clone/patch/rename + * never touches, so there is nothing to capture or re-apply here. */ cbm_store_close(store); /* Snapshot inbound cross-file edges into changed files BEFORE purging, so @@ -2784,7 +2746,6 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil incr_free_edge_capture(&edge_cap); cbm_store_free_coverage(old_cov, old_cov_count); free_mode_skipped(mode_skipped, mode_skipped_count); - free(saved_adr); cbm_gbuf_free(existing); return CBM_PIPELINE_ABORT_PRESERVE_DB; } @@ -2876,7 +2837,6 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil free(cov); cbm_store_free_coverage(old_cov, old_cov_count); free_mode_skipped(mode_skipped, mode_skipped_count); - free(saved_adr); cbm_gbuf_free(existing); return manifest_rc == CBM_DISCOVER_LIMIT_EXCEEDED ? CBM_PIPELINE_RESOURCE_LIMIT : CBM_PIPELINE_ABORT_PRESERVE_DB; @@ -2907,9 +2867,8 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil * empty table just routes the next incremental to a full rebuild. */ int persist_rc = dump_and_persist(existing, db_path, project, cbm_pipeline_cancelled_ptr(p), manifest, - manifest_count, saved_adr, cov, cov_n, &coverage_meta, NULL, 0); + manifest_count, cov, cov_n, &coverage_meta, NULL, 0); cbm_pipeline_free_semantic_manifest(manifest, manifest_count); - free(saved_adr); free(cov); cbm_store_free_coverage(old_cov, old_cov_count); free_mode_skipped(mode_skipped, mode_skipped_count); diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index e7782adef..d67ea0680 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -798,7 +798,6 @@ typedef struct { atomic_int *cancelled; const cbm_file_hash_t *manifest; int manifest_count; - const char *adr_content; const cbm_coverage_row_t *coverage; int coverage_count; cbm_coverage_meta_t coverage_meta; @@ -949,7 +948,6 @@ void cbm_pipeline_incremental_test_force_legacy_partial_once(void); void cbm_pipeline_incremental_test_fail_after_stage_dump_once(void); void cbm_pipeline_incremental_test_cancel_after_predump_once(void); void cbm_pipeline_incremental_test_cancel_after_destination_prepare_once(void); -void cbm_pipeline_incremental_test_fail_adr_capture_once(void); typedef void (*cbm_pipeline_test_hook_fn)(void *userdata); void cbm_pipeline_incremental_test_before_final_manifest_once(cbm_pipeline_test_hook_fn hook, void *userdata); diff --git a/src/store/store.c b/src/store/store.c index fa074cb5d..6024fd4fc 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -151,6 +151,15 @@ struct cbm_store { sqlite3_stmt *stmt_get_file_hashes; sqlite3_stmt *stmt_delete_file_hash; sqlite3_stmt *stmt_delete_file_hashes; + + /* ADRs live in a per-project sidecar DB (".adr.db"), NOT in the + * graph DB, so a reindex that rebuilds/replaces the graph DB never touches + * them (removing the capture/re-apply race entirely). The read-write handle + * is lazily opened on the first ADR write/migration and cached for the + * store's lifetime; NULL for a :memory: store, where ADRs stay in the graph + * DB (never file-swapped, no race). Reads open a short-lived read-only + * handle so a query-only cache is never mutated. */ + sqlite3 *adr_db; }; /* ── Helpers ────────────────────────────────────────────────────── */ @@ -163,6 +172,13 @@ static void store_set_error_sqlite(cbm_store_t *s, const char *prefix) { snprintf(s->errbuf, sizeof(s->errbuf), "%s: %s", prefix, sqlite3_errmsg(s->db)); } +/* Like store_set_error_sqlite but reads the message from an explicit + * connection — used for the ADR sidecar DB, whose errors do not live on + * s->db. */ +static void store_set_error_sqlite_on(cbm_store_t *s, sqlite3 *db, const char *prefix) { + snprintf(s->errbuf, sizeof(s->errbuf), "%s: %s", prefix, sqlite3_errmsg(db)); +} + static int exec_sql(cbm_store_t *s, const char *sql) { if (!s || !s->db) { return CBM_STORE_ERR; @@ -1300,6 +1316,11 @@ void cbm_store_close(cbm_store_t *s) { /* Use sqlite3_close_v2 — auto-deallocates when last statement finalizes. * Prevents ASan false-positive leaks from sqlite3 internal state. */ sqlite3_close_v2(s->db); + /* ADR sidecar: checkpoint + close its own connection (file stores only). */ + if (s->adr_db) { + (void)sqlite3_wal_checkpoint_v2(s->adr_db, NULL, SQLITE_CHECKPOINT_PASSIVE, NULL, NULL); + sqlite3_close_v2(s->adr_db); + } safe_str_free(&s->db_path); free(s); } @@ -9579,7 +9600,257 @@ void cbm_adr_sections_free(cbm_adr_sections_t *s) { memset(s, 0, sizeof(*s)); } +/* ── ADR sidecar store ────────────────────────────────────────────── + * ADRs are kept in a per-project sidecar SQLite DB (".adr.db"), so a + * reindex that deletes/replaces the graph DB never disturbs them (no capture, + * re-read or re-apply, no lock). adr_conn() returns the connection ADR ops run + * on: the sidecar for a file-backed store (opened, schema-created and migrated + * once, then cached), or the graph DB for a :memory: store (never file-swapped, + * so there is no race to avoid there). */ + +/* The sidecar's on-disk path (".adr.db"), routed through + * cbm_path_for_file_api so a long path gets the Windows \\?\ prefix (like + * store_open_internal). Returns false for a :memory: store or an over-long + * path. `raw` receives the plain path for existence checks/unlinks; `api` + * receives the file-API form used to open. */ +static bool adr_sidecar_path(const cbm_store_t *s, char *raw, size_t raw_sz, char *api, + size_t api_sz) { + if (!s->db_path) { + return false; + } + int n = snprintf(raw, raw_sz, "%s.adr.db", s->db_path); + if (n <= 0 || (size_t)n >= raw_sz) { + return false; + } + return cbm_path_for_file_api(raw, api, api_sz); +} + +/* Match the graph store's locking so cross-process ADR access waits rather than + * failing: WAL + a 10 s busy timeout (SQLite's own lock wait, not a hand-rolled + * poll). Best-effort: a read-only sidecar rejects journal_mode, which is fine. */ +static void adr_configure(sqlite3 *db) { + (void)sqlite3_exec(db, "PRAGMA busy_timeout = 10000;", NULL, NULL, NULL); + (void)sqlite3_exec(db, "PRAGMA journal_mode = WAL;", NULL, NULL, NULL); +} + +static int adr_ensure_schema(sqlite3 *db) { + /* project_summaries has the same shape as the graph DB's, so migration is a + * plain row copy and the cbm_adr_t mapping is unchanged. adr_meta carries a + * one-time "migrated" marker so the legacy copy runs exactly once and can + * never resurrect a row deleted after migration. */ + int rc = sqlite3_exec(db, + "CREATE TABLE IF NOT EXISTS project_summaries (" + " project TEXT PRIMARY KEY," + " summary TEXT NOT NULL," + " source_hash TEXT NOT NULL," + " created_at TEXT NOT NULL," + " updated_at TEXT NOT NULL" + ");" + "CREATE TABLE IF NOT EXISTS adr_meta (" + " key TEXT PRIMARY KEY," + " value TEXT NOT NULL" + ");", + NULL, NULL, NULL); + return (rc == SQLITE_OK) ? CBM_STORE_OK : CBM_STORE_ERR; +} + +/* True once the one-time legacy migration has been recorded in the sidecar. */ +static bool adr_migrated(sqlite3 *adb) { + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(adb, "SELECT 1 FROM adr_meta WHERE key='migrated' LIMIT 1;", + CBM_NOT_FOUND, &st, NULL) != SQLITE_OK) { + return false; + } + bool done = sqlite3_step(st) == SQLITE_ROW; + sqlite3_finalize(st); + return done; +} + +/* Copy any legacy ADR rows from the graph DB's project_summaries into the + * sidecar (INSERT OR IGNORE, so a local row is never clobbered), then set the + * one-time marker. The marker check, copy and marker insert run in ONE + * BEGIN IMMEDIATE transaction on the sidecar, so two processes migrating at + * once cannot re-copy a row the other just deleted. Returns CBM_STORE_ERR on + * any failure (transaction rolled back) so callers can fail closed and preserve + * the old generation. No-op once the marker is set. */ +/* Copy every ADR row from the graph DB's project_summaries into the sidecar with + * INSERT OR IGNORE (so a row already present locally is never overwritten). Must + * run inside a transaction on the sidecar connection. Returns OK/ERR; does not + * touch the `migrated` marker. */ +static int adr_copy_rows(cbm_store_t *s, sqlite3 *adb) { + sqlite3_stmt *probe = NULL; + if (sqlite3_prepare_v2( + s->db, + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_summaries' LIMIT 1;", + CBM_NOT_FOUND, &probe, NULL) != SQLITE_OK) { + return CBM_STORE_ERR; + } + bool has_legacy = sqlite3_step(probe) == SQLITE_ROW; + sqlite3_finalize(probe); + if (!has_legacy) { + return CBM_STORE_OK; /* no source table -> nothing to copy */ + } + + sqlite3_stmt *sel = NULL; + if (sqlite3_prepare_v2(s->db, + "SELECT project, summary, source_hash, created_at, updated_at " + "FROM project_summaries;", + CBM_NOT_FOUND, &sel, NULL) != SQLITE_OK) { + return CBM_STORE_ERR; + } + sqlite3_stmt *ins = NULL; + if (sqlite3_prepare_v2(adb, + "INSERT OR IGNORE INTO project_summaries " + "(project, summary, source_hash, created_at, updated_at) " + "VALUES (?1, ?2, ?3, ?4, ?5);", + CBM_NOT_FOUND, &ins, NULL) != SQLITE_OK) { + sqlite3_finalize(sel); + return CBM_STORE_ERR; + } + int rc = SQLITE_DONE; + while ((rc = sqlite3_step(sel)) == SQLITE_ROW) { + for (int c = 0; c < ST_COL_5; c++) { + bind_text(ins, c + 1, (const char *)sqlite3_column_text(sel, c)); + } + int irc = sqlite3_step(ins); + sqlite3_reset(ins); + sqlite3_clear_bindings(ins); + if (irc != SQLITE_DONE) { + sqlite3_finalize(sel); + sqlite3_finalize(ins); + return CBM_STORE_ERR; + } + } + sqlite3_finalize(sel); + sqlite3_finalize(ins); + return (rc == SQLITE_DONE) ? CBM_STORE_OK : CBM_STORE_ERR; +} + +static int adr_copy_legacy_once(cbm_store_t *s, sqlite3 *adb) { + if (adr_migrated(adb)) { + return CBM_STORE_OK; /* fast path: already migrated, no lock needed */ + } + if (sqlite3_exec(adb, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) { + return CBM_STORE_ERR; + } + /* Re-check under the write lock: another process may have migrated between + * the fast-path check and acquiring the lock. */ + if (adr_migrated(adb)) { + (void)sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL); + return CBM_STORE_OK; + } + if (adr_copy_rows(s, adb) != CBM_STORE_OK) { + goto fail; + } + if (sqlite3_exec(adb, "INSERT OR IGNORE INTO adr_meta (key, value) VALUES ('migrated','1');", + NULL, NULL, NULL) != SQLITE_OK) { + goto fail; + } + if (sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL) != SQLITE_OK) { + goto fail; + } + return CBM_STORE_OK; + +fail: + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); + return CBM_STORE_ERR; +} + +/* Restore ADRs from the graph DB into the sidecar for any project the sidecar has + * no row for, WITHOUT overwriting a local one, regardless of the `migrated` + * marker. This is what import needs: an artifact carries its ADR in the graph + * DB's project_summaries, and a teammate who already indexed the project (so the + * sidecar is marked, but has no ADR) must still receive it. INSERT OR IGNORE + * guarantees a local ADR is never clobbered; running ungated by the marker is + * what distinguishes this from the one-time legacy migration. */ +static int adr_restore_if_absent(cbm_store_t *s, sqlite3 *adb) { + if (sqlite3_exec(adb, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) { + return CBM_STORE_ERR; + } + if (adr_copy_rows(s, adb) != CBM_STORE_OK) { + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); + return CBM_STORE_ERR; + } + /* Mark migrated too, so a later reindex's before-delete migration is a no-op. */ + (void)sqlite3_exec(adb, "INSERT OR IGNORE INTO adr_meta (key, value) VALUES ('migrated','1');", + NULL, NULL, NULL); + if (sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL) != SQLITE_OK) { + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); + return CBM_STORE_ERR; + } + return CBM_STORE_OK; +} + +/* Open (create) the read-write sidecar, configure locking, ensure the schema, + * run the one-time legacy migration, and cache the handle. NULL + error set on + * failure (fail closed). :memory: stores return the graph DB (no race there). */ +static sqlite3 *adr_conn_write(cbm_store_t *s) { + if (!s || !s->db) { + return NULL; + } + if (!s->db_path) { + return s->db; /* :memory: store — ADRs stay in the graph DB */ + } + if (s->adr_db) { + return s->adr_db; + } + char raw[4096]; + char api[4096]; + if (!adr_sidecar_path(s, raw, sizeof(raw), api, sizeof(api))) { + store_set_error(s, "adr sidecar path too long"); + return NULL; + } + sqlite3 *adb = NULL; + int flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE; + if (sqlite3_open_v2(api, &adb, flags, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr sidecar open"); + sqlite3_close_v2(adb); + return NULL; + } + adr_configure(adb); + if (adr_ensure_schema(adb) != CBM_STORE_OK) { + store_set_error_sqlite_on(s, adb, "adr sidecar schema"); + sqlite3_close_v2(adb); + return NULL; + } + if (adr_copy_legacy_once(s, adb) != CBM_STORE_OK) { + store_set_error(s, "adr sidecar legacy migration failed"); + sqlite3_close_v2(adb); + return NULL; + } + s->adr_db = adb; + return s->adr_db; +} + +int cbm_store_adr_migrate_once(cbm_store_t *s) { + if (!s || !s->db) { + return CBM_STORE_ERR; + } + if (!s->db_path) { + return CBM_STORE_OK; /* :memory: — ADRs already in the graph DB, nothing to move */ + } + return adr_conn_write(s) ? CBM_STORE_OK : CBM_STORE_ERR; +} + +int cbm_store_adr_restore_from_graph_if_absent(cbm_store_t *s) { + if (!s || !s->db) { + return CBM_STORE_ERR; + } + if (!s->db_path) { + return CBM_STORE_OK; /* :memory: — ADR already lives in the graph DB */ + } + sqlite3 *adb = adr_conn_write(s); + if (!adb) { + return CBM_STORE_ERR; + } + return adr_restore_if_absent(s, adb); +} + int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content) { + sqlite3 *adb = adr_conn_write(s); + if (!adb) { + return CBM_STORE_ERR; + } char now[CBM_SZ_32]; iso_now(now, sizeof(now)); @@ -9589,8 +9860,8 @@ int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content "ON CONFLICT(project) DO UPDATE SET summary=excluded.summary, " "updated_at=excluded.updated_at"; sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "adr_store"); + if (sqlite3_prepare_v2(adb, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr_store"); return CBM_STORE_ERR; } bind_text(stmt, SKIP_ONE, project); @@ -9602,22 +9873,17 @@ int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content return (rc == SQLITE_DONE) ? CBM_STORE_OK : CBM_STORE_ERR; } -int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) { - if (!s || !s->db || !project || !out) { - return CBM_STORE_ERR; - } - memset(out, 0, sizeof(*out)); - - /* ADR storage was added after the original graph schema. A readable - * legacy generation without project_summaries has no ADR to preserve; it - * is not a read failure and must remain replaceable by a full reindex. */ +/* Read one ADR row from an already-open connection into `out`. Returns + * CBM_STORE_OK / CBM_STORE_NOT_FOUND / CBM_STORE_ERR. Never writes. */ +static int adr_read_row(cbm_store_t *s, sqlite3 *adb, const char *project, cbm_adr_t *out) { + /* A readable legacy generation without project_summaries has no ADR; that is + * NOT_FOUND, not a read failure. */ sqlite3_stmt *table_stmt = NULL; int rc = sqlite3_prepare_v2( - s->db, - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_summaries' LIMIT 1;", + adb, "SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_summaries' LIMIT 1;", CBM_NOT_FOUND, &table_stmt, NULL); if (rc != SQLITE_OK) { - store_set_error_sqlite(s, "adr_get table probe"); + store_set_error_sqlite_on(s, adb, "adr_get table probe"); return CBM_STORE_ERR; } rc = sqlite3_step(table_stmt); @@ -9626,26 +9892,26 @@ int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) { return CBM_STORE_NOT_FOUND; } if (rc != SQLITE_ROW || sqlite3_finalize(table_stmt) != SQLITE_OK) { - store_set_error_sqlite(s, "adr_get table probe step"); + store_set_error_sqlite_on(s, adb, "adr_get table probe step"); return CBM_STORE_ERR; } const char *sql = "SELECT project, summary, created_at, updated_at FROM project_summaries " "WHERE project=?1"; sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "adr_get"); + if (sqlite3_prepare_v2(adb, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr_get"); return CBM_STORE_ERR; } if (bind_text(stmt, SKIP_ONE, project) != SQLITE_OK) { - store_set_error_sqlite(s, "adr_get bind"); + store_set_error_sqlite_on(s, adb, "adr_get bind"); sqlite3_finalize(stmt); return CBM_STORE_ERR; } rc = sqlite3_step(stmt); if (rc != SQLITE_ROW) { if (rc != SQLITE_DONE) { - store_set_error_sqlite(s, "adr_get step"); + store_set_error_sqlite_on(s, adb, "adr_get step"); } sqlite3_finalize(stmt); if (rc == SQLITE_DONE) { @@ -9661,13 +9927,11 @@ int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) { rc = sqlite3_finalize(stmt); if (rc != SQLITE_OK) { cbm_store_adr_free(out); - store_set_error_sqlite(s, "adr_get finalize"); + store_set_error_sqlite_on(s, adb, "adr_get finalize"); return CBM_STORE_ERR; } - - /* Every selected column is NOT NULL in the schema. A NULL here therefore - * means either allocation failure or corrupt data; never return a partial - * ADR that a full rebuild could silently drop during publication. */ + /* Every selected column is NOT NULL in the schema. A NULL here means an + * allocation failure or corruption; never return a partial ADR. */ if (!out->project || !out->content || !out->created_at || !out->updated_at) { cbm_store_adr_free(out); store_set_error(s, "adr_get: failed to copy complete ADR"); @@ -9676,16 +9940,62 @@ int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) { return CBM_STORE_OK; } +int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) { + if (!s || !s->db || !project || !out) { + return CBM_STORE_ERR; + } + memset(out, 0, sizeof(*out)); + + /* :memory: store, or a sidecar already open read-write: read it directly. */ + if (!s->db_path) { + return adr_read_row(s, s->db, project, out); + } + if (s->adr_db) { + return adr_read_row(s, s->adr_db, project, out); + } + + /* A read must never create the sidecar (a query-only cache may be + * read-only): open it READONLY if the file exists, otherwise read the + * legacy row from the graph DB without migrating. */ + char raw[4096]; + char api[4096]; + if (adr_sidecar_path(s, raw, sizeof(raw), api, sizeof(api)) && cbm_file_exists(raw)) { + sqlite3 *ro = NULL; + if (sqlite3_open_v2(api, &ro, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, ro, "adr sidecar open (ro)"); + sqlite3_close_v2(ro); + return CBM_STORE_ERR; + } + (void)sqlite3_exec(ro, "PRAGMA busy_timeout = 10000;", NULL, NULL, NULL); + /* A sidecar that exists but was never marked migrated is half-created: + * the legacy copy did not commit, so the ADR (if any) still lives only + * in the graph DB. Fall back to the graph row so a partial sidecar never + * hides an existing ADR. */ + if (!adr_migrated(ro)) { + sqlite3_close_v2(ro); + return adr_read_row(s, s->db, project, out); + } + int rc = adr_read_row(s, ro, project, out); + sqlite3_close_v2(ro); + return rc; + } + return adr_read_row(s, s->db, project, out); +} + int cbm_store_adr_delete(cbm_store_t *s, const char *project) { + sqlite3 *adb = adr_conn_write(s); + if (!adb) { + return CBM_STORE_ERR; + } const char *sql = "DELETE FROM project_summaries WHERE project=?1"; sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "adr_delete"); + if (sqlite3_prepare_v2(adb, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr_delete"); return CBM_STORE_ERR; } bind_text(stmt, SKIP_ONE, project); int rc = sqlite3_step(stmt); - int changes = sqlite3_changes(s->db); + int changes = sqlite3_changes(adb); sqlite3_finalize(stmt); if (rc != SQLITE_DONE) { return CBM_STORE_ERR; @@ -9703,13 +10013,20 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha return CBM_STORE_ERR; } - /* The read-modify-write below must be ONE transaction. Three writers - * replace this row wholesale — the indexing pipeline, the UI POST - * /api/adr handler, and manage_adr mode='update' — so an unguarded - * get/merge/store silently loses whichever of them commits between the - * read and the UPSERT. BEGIN IMMEDIATE takes the write lock up front, so a - * competing writer waits rather than being overwritten. */ - if (cbm_store_begin(s) != CBM_STORE_OK) { + /* The read-modify-write below must be ONE transaction, and it runs on the + * ADR connection (the sidecar for a file store, the graph DB for :memory:) + * — the same connection cbm_store_adr_get/store use, so the write lock + * actually covers them. Two writers replace this row wholesale — the UI + * POST /api/adr handler and manage_adr mode='update' — so an unguarded + * get/merge/store silently loses whichever commits between the read and the + * UPSERT. BEGIN IMMEDIATE takes the write lock up front, so a competing + * writer waits rather than being overwritten. */ + sqlite3 *adb = adr_conn_write(s); + if (!adb) { + return CBM_STORE_ERR; + } + if (sqlite3_exec(adb, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr_update begin"); return CBM_STORE_ERR; } @@ -9717,7 +10034,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha cbm_adr_t existing; int rc = cbm_store_adr_get(s, project, &existing); if (rc != CBM_STORE_OK) { - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); store_set_error(s, "no existing ADR to update"); return rc; } @@ -9732,7 +10049,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha if (cbm_adr_check_structure(existing.content, structure_err, (int)sizeof(structure_err)) != CBM_STORE_OK) { cbm_store_adr_free(&existing); - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); store_set_error(s, structure_err); return CBM_STORE_ERR; } @@ -9746,7 +10063,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha } if (!merged) { - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); store_set_error(s, "failed to splice ADR section"); return CBM_STORE_ERR; } @@ -9757,7 +10074,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha snprintf(msg, sizeof(msg), "merged ADR exceeds %d chars (%d chars)", CBM_ADR_MAX_LENGTH, (int)strlen(merged)); free(merged); - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); store_set_error(s, msg); return CBM_STORE_ERR; } @@ -9766,19 +10083,20 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha rc = cbm_store_adr_store(s, project, merged); free(merged); if (rc != CBM_STORE_OK) { - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); return rc; } /* Read back INSIDE the transaction so `out` is exactly what commits. */ rc = cbm_store_adr_get(s, project, out); if (rc != CBM_STORE_OK) { - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); return rc; } - if (cbm_store_commit(s) != CBM_STORE_OK) { + if (sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr_update commit"); cbm_store_adr_free(out); - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); return CBM_STORE_ERR; } return CBM_STORE_OK; diff --git a/src/store/store.h b/src/store/store.h index b6ce844f7..1c0aa1ed4 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -901,6 +901,17 @@ typedef struct { int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content); int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out); +/* Move any legacy ADR row from the graph DB's project_summaries into the + * per-project sidecar, once (guarded by a marker in the sidecar). Call before a + * rebuild deletes the old generation, and on artifact import. Returns + * CBM_STORE_ERR on failure so callers can preserve the old generation. */ +int cbm_store_adr_migrate_once(cbm_store_t *s); +/* Restore ADRs from the graph DB's project_summaries into the per-project sidecar + * for any project the sidecar has no row for, without overwriting a local one and + * regardless of the migrated marker. Used on artifact import so a teammate who + * already indexed the project (marked sidecar, no ADR) still receives the shared + * ADR the artifact carries. Returns CBM_STORE_ERR on failure. */ +int cbm_store_adr_restore_from_graph_if_absent(cbm_store_t *s); int cbm_store_adr_delete(cbm_store_t *s, const char *project); int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const char **keys, const char **values, int count, cbm_adr_t *out); diff --git a/tests/test_artifact.c b/tests/test_artifact.c index a7d96cdd9..bde780f4d 100644 --- a/tests/test_artifact.c +++ b/tests/test_artifact.c @@ -2,6 +2,7 @@ * test_artifact.c — Tests for persistent artifact export/import. */ #include "test_framework.h" +#include "sqlite3.h" /* vendored/sqlite3 — seed a legacy project_summaries row in ADR tests */ #include "store/store.h" #include "pipeline/artifact.h" #include "pipeline/pipeline.h" @@ -206,6 +207,168 @@ TEST(artifact_export_fast_roundtrip) { PASS(); } +/* The ADR travels with the exported artifact: it lives in the ".adr.db" + * sidecar, and export must carry it into graph.db.zst so an import restores it + * into the destination's sidecar (round-2 review item 7d). */ +TEST(artifact_export_roundtrip_keeps_adr) { + setup_artifact_test(); + create_test_db(g_db); + + const char *adr_text = "# Decision\nThe team chose the sidecar design."; + cbm_store_t *src = cbm_store_open_path(g_db); + ASSERT_NOT_NULL(src); + ASSERT_EQ(cbm_store_adr_store(src, "test-proj", adr_text), CBM_STORE_OK); + cbm_store_close(src); + + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); + + char import_db[1024]; + snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); + ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0); + + /* The imported project starts with the team's ADR (restored into the + * destination sidecar by import). */ + cbm_store_t *dst = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(dst); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, adr_text); + cbm_store_adr_free(&adr); + cbm_store_close(dst); + + cleanup_dir(g_tmpdir); + PASS(); +} + +/* Regression: a stale legacy `project_summaries` row that lingers in the graph DB + * after its ADR was migrated then DELETED must NOT resurrect through an export. + * With the sidecar empty, the snapshot must ship an empty table, so an import + * finds no ADR. (Before the DELETE-first fix, VACUUM INTO copied the stale row + * and import restored a deleted decision record.) */ +TEST(artifact_export_without_adr_does_not_resurrect_legacy_row) { + setup_artifact_test(); + create_test_db(g_db); + + /* Pre-sidecar layout: an ADR lingering only in the graph DB. */ + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(g_db, &raw), SQLITE_OK); + char *sql = sqlite3_mprintf( + "INSERT INTO project_summaries (project, summary, source_hash, created_at, updated_at) " + "VALUES (%Q, %Q, '', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z');", + "test-proj", "# Decision\nstale, later deleted"); + ASSERT_NOT_NULL(sql); + ASSERT_EQ(sqlite3_exec(raw, sql, NULL, NULL, NULL), SQLITE_OK); + sqlite3_free(sql); + sqlite3_close(raw); + + /* Migrate it into the sidecar (marker set), then DELETE it. The graph row + * still lingers; the sidecar is now migrated-but-empty. */ + cbm_store_t *s = cbm_store_open_path(g_db); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_adr_migrate_once(s), CBM_STORE_OK); + ASSERT_EQ(cbm_store_adr_delete(s, "test-proj"), CBM_STORE_OK); + cbm_store_close(s); + + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); + + char import_db[1024]; + snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); + ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0); + + cbm_store_t *dst = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(dst); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_NOT_FOUND); + cbm_store_adr_free(&adr); + cbm_store_close(dst); + + cleanup_dir(g_tmpdir); + PASS(); +} + +/* An import must not clobber an ADR already present locally: the restore uses + * INSERT OR IGNORE, so the teammate's own decision record survives importing a + * shared artifact. */ +TEST(artifact_import_does_not_clobber_local_adr) { + setup_artifact_test(); + create_test_db(g_db); + + /* The shared artifact carries ADR "A". */ + cbm_store_t *src = cbm_store_open_path(g_db); + ASSERT_NOT_NULL(src); + ASSERT_EQ(cbm_store_adr_store(src, "test-proj", "# Decision\nshared A"), CBM_STORE_OK); + cbm_store_close(src); + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); + + /* The importing machine already has its own local ADR "B" for this project. */ + char import_db[1024]; + snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); + cbm_store_t *local = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(local); + ASSERT_EQ(cbm_store_adr_store(local, "test-proj", "# Decision\nlocal B"), CBM_STORE_OK); + cbm_store_close(local); + + ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0); + + /* Local B is preserved — the import did not overwrite it with shared A. */ + cbm_store_t *dst = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(dst); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, "# Decision\nlocal B"); + cbm_store_adr_free(&adr); + cbm_store_close(dst); + + cleanup_dir(g_tmpdir); + PASS(); +} + +/* The shared ADR must reach a teammate who has ALREADY indexed the project: their + * sidecar is marked migrated but has no ADR row, so the marker-gated migrate-once + * would skip it. Import restores-if-absent (ungated), so the artifact's ADR "A" + * arrives. (On main, import replaced the whole DB, so the ADR always came through; + * the sidecar must preserve that.) */ +TEST(artifact_import_restores_adr_when_local_absent) { + setup_artifact_test(); + create_test_db(g_db); + + /* The shared artifact carries ADR "A". */ + cbm_store_t *src = cbm_store_open_path(g_db); + ASSERT_NOT_NULL(src); + ASSERT_EQ(cbm_store_adr_store(src, "test-proj", "# Decision\nshared A"), CBM_STORE_OK); + cbm_store_close(src); + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); + + /* The destination is already indexed: a marked sidecar with NO ADR row. */ + char import_db[1024]; + snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); + create_test_db(import_db); + cbm_store_t *pre = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(pre); + ASSERT_EQ(cbm_store_adr_migrate_once(pre), CBM_STORE_OK); /* marks the sidecar, no ADR */ + cbm_adr_t none = {0}; + ASSERT_EQ(cbm_store_adr_get(pre, "test-proj", &none), CBM_STORE_NOT_FOUND); + cbm_store_adr_free(&none); + cbm_store_close(pre); + + ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0); + + /* The shared ADR "A" is restored despite the pre-existing marker. */ + cbm_store_t *dst = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(dst); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, "# Decision\nshared A"); + cbm_store_adr_free(&adr); + cbm_store_close(dst); + + cleanup_dir(g_tmpdir); + PASS(); +} + TEST(artifact_export_best_roundtrip) { setup_artifact_test(); create_test_db(g_db); @@ -1096,6 +1259,10 @@ SUITE(artifact) { RUN_TEST(artifact_repo_path_shell_safe_rejects_injection); RUN_TEST(artifact_repo_path_shell_safe_rejects_cmd_metachars_on_windows); RUN_TEST(artifact_export_fast_roundtrip); + RUN_TEST(artifact_export_roundtrip_keeps_adr); + RUN_TEST(artifact_export_without_adr_does_not_resurrect_legacy_row); + RUN_TEST(artifact_import_does_not_clobber_local_adr); + RUN_TEST(artifact_import_restores_adr_when_local_absent); RUN_TEST(artifact_export_best_roundtrip); RUN_TEST(artifact_exists_check); RUN_TEST(artifact_commit_hash); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index ffa9ea3a3..969ab8e88 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -7995,6 +7995,57 @@ TEST(tool_delete_project_mutation_guard_blocks_then_releases) { PASS(); } +/* delete_project removes the ADR sidecar with the project, and a re-index of a + * same-named project afterwards starts with no ADR (round-2 review item 7c). */ +TEST(tool_delete_project_removes_adr_sidecar) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-mcp-delete-adr-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); + } + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + const char *project = "adr-delete-project"; + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + cbm_store_t *setup = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(setup); + ASSERT_EQ(cbm_store_upsert_project(setup, project, "/tmp/adr-delete-project"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_adr_store(setup, project, "# Decision\nkeep it"), CBM_STORE_OK); + cbm_store_close(setup); + + char adr_path[CBM_SZ_1K]; + snprintf(adr_path, sizeof(adr_path), "%s.adr.db", db_path); + ASSERT_TRUE(cbm_file_exists(adr_path)); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_handle_tool(srv, "delete_project", "{\"project\":\"adr-delete-project\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "deleted")); + free(resp); + + ASSERT_FALSE(cbm_file_exists(db_path)); + ASSERT_FALSE(cbm_file_exists(adr_path)); /* sidecar removed with the project */ + + /* A fresh store of the same name starts with no ADR. */ + cbm_store_t *fresh = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(fresh); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(fresh, project, &adr), CBM_STORE_NOT_FOUND); + cbm_store_adr_free(&adr); + cbm_store_close(fresh); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + cbm_rmdir(cache); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + PASS(); +} + TEST(tool_index_repository_mutation_guard_blocks_before_local_worker) { char root[CBM_SZ_1K]; (void)snprintf(root, sizeof(root), "%s/cbm-index-guard-XXXXXX", cbm_tmpdir()); @@ -20772,6 +20823,7 @@ SUITE(mcp) { * running the much larger MCP behavior suite. */ SUITE(mcp_mutation_guard) { RUN_TEST(tool_delete_project_mutation_guard_blocks_then_releases); + RUN_TEST(tool_delete_project_removes_adr_sidecar); RUN_TEST(tool_index_repository_mutation_guard_blocks_before_local_worker); RUN_TEST(tool_manage_adr_mutation_guard_balances_success); RUN_TEST(tool_manage_adr_read_paths_skip_blocking_mutation_guard); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 6ef16a89b..eb7857ff1 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -540,6 +540,217 @@ TEST(pipeline_adr_survives_full_reindex) { PASS(); } +/* An ADR written from a test hook BETWEEN the start of a reindex and its + * publication survives — the write lands in the ".adr.db" sidecar, which the + * graph-DB rebuild never touches (round-2 review item 7a, full route). */ +typedef struct { + char db_path[512]; + char project[256]; + const char *adr; +} adr_hook_ctx_t; + +static void write_adr_before_final_manifest(void *userdata) { + adr_hook_ctx_t *c = (adr_hook_ctx_t *)userdata; + cbm_store_t *s = cbm_store_open_path_existing(c->db_path); + if (!s) { + s = cbm_store_open_path(c->db_path); + } + if (s) { + (void)cbm_store_adr_store(s, c->project, c->adr); + cbm_store_close(s); + } +} + +TEST(pipeline_adr_written_during_reindex_survives) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_hook_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char path[512]; + snprintf(path, sizeof(path), "%s/main.py", tmp); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + adr_hook_ctx_t ctx = {.adr = "# Decision\nWritten mid-reindex."}; + snprintf(ctx.db_path, sizeof(ctx.db_path), "%s", db_path); + snprintf(ctx.project, sizeof(ctx.project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); + + /* Force a full reindex, writing the ADR from the publication hook. */ + for (int i = 0; i < 4; i++) { + snprintf(path, sizeof(path), "%s/extra%d.py", tmp, i); + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def g%d():\n return %d\n", i, i); + fclose(f); + } + cbm_pipeline_incremental_test_reset_faults(); + cbm_pipeline_incremental_test_before_final_manifest_once(write_adr_before_final_manifest, &ctx); + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + ASSERT_EQ(cbm_pipeline_run(p2), 0); + /* Pin the route so this stays a full-rebuild test if routing changes. */ + ASSERT_EQ(cbm_pipeline_incremental_test_last_route(), CBM_INCREMENTAL_ROUTE_FORCED_FULL); + cbm_pipeline_free(p2); + cbm_pipeline_incremental_test_reset_faults(); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(s, ctx.project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, ctx.adr); + cbm_store_adr_free(&adr); + cbm_store_close(s); + + rm_rf(tmp); + PASS(); +} + +/* Migrate-before-rebuild fails closed: if the sidecar cannot be opened (here a + * DIRECTORY sits at ".adr.db"), the reindex must NOT delete the old + * generation — it returns CBM_PIPELINE_ABORT_PRESERVE_DB and the prior graph is + * left intact. Deterministic, no hook. */ +TEST(pipeline_adr_migrate_failure_preserves_generation) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_migfail_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char path[512]; + snprintf(path, sizeof(path), "%s/main.py", tmp); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); + + cbm_store_t *before = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(before); + int nodes_before = cbm_store_count_nodes(before, project); + cbm_store_close(before); + ASSERT_TRUE(nodes_before > 0); + + /* A directory at ".adr.db" makes the sidecar open (and thus the + * migrate-before-delete step) fail deterministically. */ + char adr_dir[600]; + snprintf(adr_dir, sizeof(adr_dir), "%s.adr.db", db_path); + ASSERT_EQ(cbm_mkdir(adr_dir), 0); + + /* Force a full reindex; migrate-before-delete must fail closed. */ + for (int i = 0; i < 4; i++) { + snprintf(path, sizeof(path), "%s/extra%d.py", tmp, i); + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def g%d():\n return %d\n", i, i); + fclose(f); + } + cbm_pipeline_incremental_test_reset_faults(); + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + int rc = cbm_pipeline_run(p2); + cbm_pipeline_free(p2); + ASSERT_EQ(rc, CBM_PIPELINE_ABORT_PRESERVE_DB); + + /* Old generation intact: the graph DB was not deleted or rebuilt. */ + ASSERT_TRUE(cbm_file_exists(db_path)); + cbm_store_t *after = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(after); + ASSERT_EQ(cbm_store_count_nodes(after, project), nodes_before); + cbm_store_close(after); + + rm_rf(tmp); + PASS(); +} + +/* Upgrade order: a legacy ADR row (pre-sidecar, in the graph DB) is migrated + * BEFORE a forced-full reindex deletes the old generation, with NO ADR read + * first — the pipeline's migrate-before-rebuild step must move it (item 7b). */ +TEST(pipeline_adr_legacy_migrates_before_forced_reindex) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_upgrade_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char path[512]; + snprintf(path, sizeof(path), "%s/main.py", tmp); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); + + /* Seed the ADR directly into the graph DB's project_summaries (the legacy + * layout), bypassing the sidecar entirely. */ + static const char adr_text[] = "# Decision\nLegacy row, migrated before rebuild."; + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(db_path, &raw), SQLITE_OK); + sqlite3_exec(raw, + "CREATE TABLE IF NOT EXISTS project_summaries (project TEXT PRIMARY KEY," + " summary TEXT NOT NULL, source_hash TEXT NOT NULL, created_at TEXT NOT NULL," + " updated_at TEXT NOT NULL);", + NULL, NULL, NULL); + char *sql = sqlite3_mprintf( + "INSERT OR REPLACE INTO project_summaries VALUES (%Q, %Q, '', '2020-01-01T00:00:00Z'," + " '2020-01-01T00:00:00Z');", + project, adr_text); + ASSERT_NOT_NULL(sql); + ASSERT_EQ(sqlite3_exec(raw, sql, NULL, NULL, NULL), SQLITE_OK); + sqlite3_free(sql); + sqlite3_close(raw); + + /* No ADR read here. Force a full reindex (which deletes the old graph DB). */ + for (int i = 0; i < 4; i++) { + snprintf(path, sizeof(path), "%s/extra%d.py", tmp, i); + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def g%d():\n return %d\n", i, i); + fclose(f); + } + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + ASSERT_EQ(cbm_pipeline_run(p2), 0); + cbm_pipeline_free(p2); + + /* The legacy ADR was migrated to the sidecar before the delete, so it is + * still readable. */ + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(s, project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, adr_text); + cbm_store_adr_free(&adr); + cbm_store_close(s); + + rm_rf(tmp); + PASS(); +} + TEST(pipeline_structure_edges) { if (setup_test_repo() != 0) { FAIL("failed to create temp dir"); @@ -3009,6 +3220,60 @@ TEST(pipeline_closure_repair_body_edit_converges_with_fresh_full) { PASS(); } +/* Companion to pipeline_adr_written_during_reindex_survives (the full route): + * an ADR written from the publication hook mid-reindex must also survive the + * closure-DELTA route (item 7a — "both the full and the delta route"). The + * delta route clones/patches/renames the graph DB but never touches the + * ".adr.db" sidecar, so the hook-written ADR is preserved. A body-only edit + * forces the closure-repair (delta) route, which we assert explicitly so this + * can't silently pass on a full rebuild. */ +TEST(pipeline_adr_written_during_delta_reindex_survives) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_delta_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + closure_probe_repo(tmp); + char db[512]; + snprintf(db, sizeof(db), "%s/closure.db", tmp); + + cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(baseline); + ASSERT_EQ(cbm_pipeline_run(baseline), 0); + adr_hook_ctx_t ctx = {.adr = "# Decision\nWritten mid-delta-reindex."}; + snprintf(ctx.db_path, sizeof(ctx.db_path), "%s", db); + snprintf(ctx.project, sizeof(ctx.project), "%s", cbm_pipeline_project_name(baseline)); + cbm_pipeline_free(baseline); + + /* Body-only edit (same exported surface) → closure-repair delta route. */ + write_temp_file(tmp, "lib.ts", + "export function closureProbeHelper(x: number): string {\n" + " const doubled = x + x;\n" + " return String(doubled);\n" + "}\n"); + + cbm_pipeline_incremental_test_reset_faults(); + cbm_pipeline_incremental_test_before_final_manifest_once(write_adr_before_final_manifest, &ctx); + cbm_pipeline_t *incr = cbm_pipeline_new(tmp, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(incr); + ASSERT_EQ(cbm_pipeline_run(incr), 0); + cbm_incremental_route_t route = cbm_pipeline_incremental_test_last_route(); + cbm_pipeline_free(incr); + cbm_pipeline_incremental_test_reset_faults(); + /* Must be the DELTA route, not a full rebuild — otherwise this proves nothing. */ + ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR); + + cbm_store_t *s = cbm_store_open_path(db); + ASSERT_NOT_NULL(s); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(s, ctx.project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, ctx.adr); + cbm_store_adr_free(&adr); + cbm_store_close(s); + + th_rmtree(tmp); + PASS(); +} + /* Removing a definition (no additions) keeps the closure route AND must drop * the dependent's stale CALL_REFERENCE. This is the assertion legacy partial * could never pass: its QN-keyed re-link resurrected yesterday's edge. */ @@ -3612,7 +3877,6 @@ TEST(pipeline_publication_never_uses_a_predictable_staging_path) { .cancelled = NULL, .manifest = NULL, .manifest_count = 0, - .adr_content = NULL, .coverage = NULL, .coverage_count = 0, }; @@ -4992,79 +5256,215 @@ TEST(pipeline_incremental_successful_publication_preserves_adr) { PASS(); } -/* A forced-full rebuild must never erase an ADR merely because the old - * generation could not be read completely. The capture is part of the - * publication transaction: failure preserves both graph and ADR. */ -TEST(pipeline_full_adr_capture_failure_preserves_previous_generation) { +/* An ADR written in the pre-sidecar layout (a row in the graph DB's + * project_summaries) is migrated into the ".adr.db" sidecar the first time + * it is read, and then survives a full reindex — so upgrading needs no reindex + * and loses no decision record. */ +TEST(pipeline_adr_migrates_from_legacy_graph_row) { char tmp[256]; - snprintf(tmp, sizeof(tmp), "/tmp/cbm_publish_adr_capture_XXXXXX"); - ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); - write_temp_file(tmp, "generation.py", "def BeforeAdrCapture():\n return 1\n"); + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_migrate_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } char db_path[512]; - snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp); + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char main_py[512]; + snprintf(main_py, sizeof(main_py), "%s/main.py", tmp); + FILE *f = fopen(main_py, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); - cbm_pipeline_incremental_test_reset_faults(); - cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); - ASSERT_NOT_NULL(baseline); - ASSERT_EQ(cbm_pipeline_run(baseline), 0); + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); char project[256]; - snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(baseline)); - cbm_pipeline_free(baseline); + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); - static const char adr_text[] = "# Decision\nADR capture is fail-closed."; - cbm_store_t *adr_store = cbm_store_open_path(db_path); - ASSERT_NOT_NULL(adr_store); - ASSERT_EQ(cbm_store_adr_store(adr_store, project, adr_text), CBM_STORE_OK); - cbm_store_close(adr_store); + /* Seed the ADR directly into the graph DB's project_summaries — the layout + * before the sidecar existed — bypassing cbm_store_adr_store (which now + * writes the sidecar). */ + static const char adr_text[] = "# Decision\nLegacy row migrates to the sidecar."; + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(db_path, &raw), SQLITE_OK); + char *sql = sqlite3_mprintf( + "INSERT INTO project_summaries (project, summary, source_hash, created_at, updated_at) " + "VALUES (%Q, %Q, '', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z');", + project, adr_text); + ASSERT_NOT_NULL(sql); + ASSERT_EQ(sqlite3_exec(raw, sql, NULL, NULL, NULL), SQLITE_OK); + sqlite3_free(sql); + sqlite3_close(raw); - write_temp_file(tmp, "generation.py", "def AfterAdrCapture():\n return 2\n"); - cbm_pipeline_incremental_test_fail_adr_capture_once(); - cbm_pipeline_t *faulted = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); - ASSERT_NOT_NULL(faulted); - int faulted_rc = cbm_pipeline_run(faulted); - cbm_pipeline_free(faulted); + char adr_db[600]; + snprintf(adr_db, sizeof(adr_db), "%s.adr.db", db_path); + ASSERT_TRUE(!cbm_file_exists(adr_db)); /* no sidecar yet */ - int faulted_before = -1; - int faulted_after = -1; - observe_named_generation(db_path, project, "BeforeAdrCapture", "AfterAdrCapture", - &faulted_before, &faulted_after); - cbm_store_t *preserved = cbm_store_open_path(db_path); - ASSERT_NOT_NULL(preserved); - cbm_adr_t preserved_adr = {0}; - int preserved_adr_rc = cbm_store_adr_get(preserved, project, &preserved_adr); - bool preserved_adr_matches = preserved_adr_rc == CBM_STORE_OK && preserved_adr.content && - strcmp(preserved_adr.content, adr_text) == 0; - cbm_store_adr_free(&preserved_adr); - cbm_store_close(preserved); + /* A READ returns the legacy row from the graph DB and must NOT create the + * sidecar (a query-only cache must stay unwritten). */ + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(s, project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, adr_text); + cbm_store_adr_free(&adr); + cbm_store_close(s); + ASSERT_TRUE(!cbm_file_exists(adr_db)); /* read did not create the sidecar */ - cbm_pipeline_incremental_test_reset_faults(); - cbm_pipeline_t *retry = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); - ASSERT_NOT_NULL(retry); - int retry_rc = cbm_pipeline_run(retry); - cbm_pipeline_free(retry); - int retry_before = -1; - int retry_after = -1; - observe_named_generation(db_path, project, "BeforeAdrCapture", "AfterAdrCapture", &retry_before, - &retry_after); - cbm_store_t *published = cbm_store_open_path(db_path); - ASSERT_NOT_NULL(published); - cbm_adr_t published_adr = {0}; - int published_adr_rc = cbm_store_adr_get(published, project, &published_adr); - bool published_adr_matches = published_adr_rc == CBM_STORE_OK && published_adr.content && - strcmp(published_adr.content, adr_text) == 0; - cbm_store_adr_free(&published_adr); - cbm_store_close(published); - cbm_pipeline_incremental_test_reset_faults(); - th_rmtree(tmp); + /* Force a full reindex; routing migrates the legacy row into the sidecar + * before the old generation is deleted, so the ADR survives. */ + for (int i = 0; i < 4; i++) { + char extra[512]; + snprintf(extra, sizeof(extra), "%s/extra%d.py", tmp, i); + f = fopen(extra, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def g%d():\n return %d\n", i, i); + fclose(f); + } + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + ASSERT_EQ(cbm_pipeline_run(p2), 0); + cbm_pipeline_free(p2); - ASSERT_EQ(faulted_rc, CBM_PIPELINE_ABORT_PRESERVE_DB); - ASSERT_EQ(faulted_before, 1); - ASSERT_EQ(faulted_after, 0); - ASSERT_TRUE(preserved_adr_matches); - ASSERT_EQ(retry_rc, 0); - ASSERT_EQ(retry_before, 0); - ASSERT_EQ(retry_after, 1); - ASSERT_TRUE(published_adr_matches); + cbm_store_t *s2 = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s2); + cbm_adr_t adr2 = {0}; + ASSERT_EQ(cbm_store_adr_get(s2, project, &adr2), CBM_STORE_OK); + ASSERT_NOT_NULL(adr2.content); + ASSERT_STR_EQ(adr2.content, adr_text); + cbm_store_adr_free(&adr2); + cbm_store_close(s2); + + rm_rf(tmp); + PASS(); +} + +/* A sidecar that exists but was never marked `migrated` is half-created (a crash + * or a killed process between CREATE and the migration COMMIT). A read must fall + * back to the legacy graph row so a partial sidecar never hides an ADR. */ +TEST(pipeline_adr_half_created_sidecar_falls_back_to_legacy) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_halfcreated_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char main_py[512]; + snprintf(main_py, sizeof(main_py), "%s/main.py", tmp); + FILE *f = fopen(main_py, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); + + /* Legacy ADR still only in the graph DB (pre-sidecar layout). */ + static const char adr_text[] = "# Decision\nHalf-created sidecar must not hide me."; + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(db_path, &raw), SQLITE_OK); + char *sql = sqlite3_mprintf( + "INSERT INTO project_summaries (project, summary, source_hash, created_at, updated_at) " + "VALUES (%Q, %Q, '', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z');", + project, adr_text); + ASSERT_NOT_NULL(sql); + ASSERT_EQ(sqlite3_exec(raw, sql, NULL, NULL, NULL), SQLITE_OK); + sqlite3_free(sql); + sqlite3_close(raw); + + /* Fabricate a HALF-CREATED sidecar: schema present, NO `migrated` marker. */ + char adr_db[600]; + snprintf(adr_db, sizeof(adr_db), "%s.adr.db", db_path); + sqlite3 *side = NULL; + ASSERT_EQ(sqlite3_open(adr_db, &side), SQLITE_OK); + ASSERT_EQ(sqlite3_exec(side, + "CREATE TABLE project_summaries (project TEXT PRIMARY KEY, summary TEXT " + "NOT NULL, source_hash TEXT NOT NULL, created_at TEXT NOT NULL, " + "updated_at TEXT NOT NULL);" + "CREATE TABLE adr_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);", + NULL, NULL, NULL), + SQLITE_OK); + sqlite3_close(side); + ASSERT_TRUE(cbm_file_exists(adr_db)); /* sidecar exists but is unmarked */ + + /* The read must fall back to the legacy graph row, not report NOT_FOUND. */ + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(s, project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, adr_text); + cbm_store_adr_free(&adr); + cbm_store_close(s); + + rm_rf(tmp); + PASS(); +} + +/* ADRs live in the ".adr.db" sidecar, separate from the graph DB, and + * removing that sidecar (what delete_project does) removes the ADR store. */ +TEST(pipeline_adr_sidecar_is_separate_and_removable) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_sidecar_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char main_py[512]; + snprintf(main_py, sizeof(main_py), "%s/main.py", tmp); + FILE *f = fopen(main_py, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); + + static const char adr_text[] = "# Decision\nADRs live in the sidecar."; + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_adr_store(s, project, adr_text), CBM_STORE_OK); + cbm_store_close(s); + + /* The ADR is in the sidecar, NOT in the graph DB's project_summaries. */ + char adr_db[600]; + snprintf(adr_db, sizeof(adr_db), "%s.adr.db", db_path); + ASSERT_TRUE(cbm_file_exists(adr_db)); + + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(db_path, &raw), SQLITE_OK); + sqlite3_stmt *st = NULL; + ASSERT_EQ(sqlite3_prepare_v2(raw, "SELECT COUNT(*) FROM project_summaries;", -1, &st, NULL), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(st), SQLITE_ROW); + int graph_rows = sqlite3_column_int(st, 0); + sqlite3_finalize(st); + sqlite3_close(raw); + ASSERT_EQ(graph_rows, 0); + + /* Removing the sidecar (as delete_project does) removes the ADR store. */ + ASSERT_EQ(cbm_unlink(adr_db), 0); + cbm_store_t *s2 = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s2); + cbm_adr_t adr = {0}; + int rc = cbm_store_adr_get(s2, project, &adr); + cbm_store_adr_free(&adr); + cbm_store_close(s2); + ASSERT_EQ(rc, CBM_STORE_NOT_FOUND); + + rm_rf(tmp); PASS(); } @@ -15475,7 +15875,13 @@ SUITE(pipeline_semantic_manifest_repro) { RUN_TEST(pipeline_full_persist_failure_after_stage_dump_preserves_previous_generation); RUN_TEST(pipeline_incremental_persist_failure_preserves_previous_generation_and_retries); RUN_TEST(pipeline_incremental_successful_publication_preserves_adr); - RUN_TEST(pipeline_full_adr_capture_failure_preserves_previous_generation); + RUN_TEST(pipeline_adr_migrates_from_legacy_graph_row); + RUN_TEST(pipeline_adr_sidecar_is_separate_and_removable); + RUN_TEST(pipeline_adr_half_created_sidecar_falls_back_to_legacy); + RUN_TEST(pipeline_adr_written_during_reindex_survives); + RUN_TEST(pipeline_adr_written_during_delta_reindex_survives); + RUN_TEST(pipeline_adr_migrate_failure_preserves_generation); + RUN_TEST(pipeline_adr_legacy_migrates_before_forced_reindex); RUN_TEST(pipeline_semantic_manifest_rejects_non_directory_root); RUN_TEST(pipeline_full_reindex_quarantines_corrupt_destination_without_overwrite); RUN_TEST(pipeline_full_reindex_replaces_legacy_schema_without_quarantine); diff --git a/tests/test_store_arch.c b/tests/test_store_arch.c index 4e9f59068..242c0cafc 100644 --- a/tests/test_store_arch.c +++ b/tests/test_store_arch.c @@ -579,6 +579,22 @@ TEST(adr_store_and_retrieve) { PASS(); } +TEST(adr_restore_from_graph_if_absent_memory_is_noop) { + /* A :memory: store keeps ADRs in the graph DB, so restore-if-absent is a + * well-formed no-op (returns OK) and leaves an existing ADR untouched. */ + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_adr_store(s, "test", "v1"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_adr_restore_from_graph_if_absent(s), CBM_STORE_OK); + cbm_adr_t adr; + ASSERT_EQ(cbm_store_adr_get(s, "test", &adr), CBM_STORE_OK); + ASSERT_STR_EQ(adr.content, "v1"); + cbm_store_adr_free(&adr); + cbm_store_close(s); + PASS(); +} + TEST(adr_upsert) { cbm_store_t *s = cbm_store_open_memory(); ASSERT_NOT_NULL(s); @@ -1693,6 +1709,7 @@ SUITE(store_arch) { /* ADR */ RUN_TEST(adr_store_and_retrieve); RUN_TEST(adr_upsert); + RUN_TEST(adr_restore_from_graph_if_absent_memory_is_noop); RUN_TEST(adr_delete); RUN_TEST(adr_delete_not_found); RUN_TEST(adr_parse_sections_basic);