From bcbc95829fb2bd7122eeb1397f804845b749adc1 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Fri, 3 Jul 2026 18:33:23 -0400 Subject: [PATCH] feat(store): summarize overlay node read view Add a read-only overlay node view summary that computes canonical visible nodes plus owned nodes from the latest ready overlay per file. This is the first tested base-minus-tombstone-plus-overlay read primitive and does not alter existing query behavior. Validation: CBM_ONLY_SUITE=store_nodes make -f Makefile.cbm -j8 test (89 passed); CBM_ONLY_SUITE=sqlite_writer make -f Makefile.cbm -j8 test (10 passed); make -f Makefile.cbm -j8 cbm; bash scripts/check-source-safety.sh; git diff --check. Signed-off-by: Andrew Hundt --- src/store/store.c | 62 +++++++++++++++++++++++++++ src/store/store.h | 14 +++++++ tests/test_store_nodes.c | 90 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+) diff --git a/src/store/store.c b/src/store/store.c index 722d733e9..97edcaa95 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -4926,6 +4926,68 @@ int cbm_store_publish_overlay_file_delta(cbm_store_t *s, return CBM_STORE_OK; } +int cbm_store_get_overlay_node_view_summary(cbm_store_t *s, const char *project, + cbm_store_overlay_node_view_summary_t *out) { + if (out) { + memset(out, 0, sizeof(*out)); + } + if (!s || !s->db || !project || !project[0] || !out) { + if (s) { + store_set_error(s, "overlay_node_view_summary: invalid argument"); + } + return CBM_STORE_ERR; + } + + static const char sql[] = + "WITH active_files AS (" + " SELECT t.rel_path, MAX(t.overlay_generation) AS overlay_generation" + " FROM overlay_tombstones t" + " JOIN overlay_generations g" + " ON g.project = t.project AND g.overlay_generation = t.overlay_generation" + " WHERE t.project = ?1 AND g.status = ?2 AND t.entity_kind = ?3 AND t.active = ?4" + " GROUP BY t.rel_path" + ")" + "SELECT " + " (SELECT COUNT(*) FROM overlay_generations g" + " WHERE g.project = ?1 AND g.status = ?2) AS ready_generations," + " (SELECT COUNT(*) FROM active_files) AS active_files," + " (SELECT COUNT(*) FROM nodes n" + " WHERE n.project = ?1" + " AND NOT EXISTS (SELECT 1 FROM active_files af" + " WHERE af.rel_path = n.file_path)) AS canonical_visible," + " (SELECT COUNT(*) FROM overlay_nodes n" + " JOIN active_files af" + " ON af.rel_path = n.rel_path AND af.overlay_generation = n.overlay_generation" + " WHERE n.project = ?1 AND n.owned = ?5) AS overlay_visible;"; + + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "overlay_node_view_summary prepare"); + return CBM_STORE_ERR; + } + bind_text(stmt, ST_COL_1, project); + bind_text(stmt, ST_COL_2, CBM_STORE_OVERLAY_STATUS_READY); + bind_text(stmt, ST_COL_3, CBM_STORE_OVERLAY_TOMBSTONE_FILE); + sqlite3_bind_int(stmt, ST_COL_4, STORE_OVERLAY_TOMBSTONE_ACTIVE); + sqlite3_bind_int(stmt, ST_COL_5, STORE_OVERLAY_ROW_OWNED); + + int rc = sqlite3_step(stmt); + if (rc != SQLITE_ROW) { + sqlite3_finalize(stmt); + store_set_error_sqlite(s, "overlay_node_view_summary"); + return CBM_STORE_ERR; + } + + out->overlay_ready_generations = sqlite3_column_int(stmt, 0); + out->active_file_tombstones = sqlite3_column_int(stmt, 1); + out->canonical_nodes_visible = sqlite3_column_int(stmt, 2); + out->overlay_owned_nodes_visible = sqlite3_column_int(stmt, 3); + out->total_nodes_visible = out->canonical_nodes_visible + out->overlay_owned_nodes_visible; + + sqlite3_finalize(stmt); + return CBM_STORE_OK; +} + static bool store_file_delta_batch_shape_valid(const cbm_store_file_delta_t *const *deltas, int delta_count, const char **out_project, int64_t *out_generation) { diff --git a/src/store/store.h b/src/store/store.h index 5423dd626..776e8dd06 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -669,6 +669,20 @@ int cbm_store_publish_overlay_file_delta(cbm_store_t *s, const cbm_store_file_delta_t *delta, int64_t overlay_generation); +typedef struct { + int overlay_ready_generations; + int active_file_tombstones; + int canonical_nodes_visible; + int overlay_owned_nodes_visible; + int total_nodes_visible; +} cbm_store_overlay_node_view_summary_t; + +/* Summarize the current node read view as canonical nodes minus files with a + * ready overlay tombstone plus owned nodes from the latest ready overlay per file. + * This is a read-only helper; it does not change canonical query behavior. */ +int cbm_store_get_overlay_node_view_summary(cbm_store_t *s, const char *project, + cbm_store_overlay_node_view_summary_t *out); + /* Record derived-view freshness. Status must be one of CBM_STORE_DERIVED_STATUS_*. */ int cbm_store_set_derived_view_state(cbm_store_t *s, const char *project, const char *view_name, int64_t generation, diff --git a/tests/test_store_nodes.c b/tests/test_store_nodes.c index c80640eeb..716722b0c 100644 --- a/tests/test_store_nodes.c +++ b/tests/test_store_nodes.c @@ -1390,6 +1390,95 @@ TEST(store_overlay_file_delta_publish_rejects_failed_generation) { PASS(); } +TEST(store_overlay_node_view_summary_counts_latest_ready_overlay) { + enum { BASE_GENERATION = 1 }; + 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); + + cbm_node_t old_main = {.project = "test", + .label = "Function", + .name = "old_main", + .qualified_name = "test.old_main", + .file_path = "main.go", + .properties_json = "{}"}; + cbm_node_t stable = {.project = "test", + .label = "Function", + .name = "stable", + .qualified_name = "test.stable", + .file_path = "stable.go", + .properties_json = "{}"}; + ASSERT_GT(cbm_store_upsert_node(s, &old_main), 0); + ASSERT_GT(cbm_store_upsert_node(s, &stable), 0); + + cbm_store_overlay_node_view_summary_t summary = {0}; + ASSERT_EQ(cbm_store_get_overlay_node_view_summary(s, "test", &summary), CBM_STORE_OK); + ASSERT_EQ(summary.overlay_ready_generations, 0); + ASSERT_EQ(summary.active_file_tombstones, 0); + ASSERT_EQ(summary.canonical_nodes_visible, 2); + ASSERT_EQ(summary.overlay_owned_nodes_visible, 0); + ASSERT_EQ(summary.total_nodes_visible, 2); + + int64_t first_overlay = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, &first_overlay), + CBM_STORE_OK); + cbm_node_t first_nodes[] = { + {.project = "test", + .label = "Function", + .name = "new_main", + .qualified_name = "test.new_main", + .file_path = "main.go", + .properties_json = "{}"}, + {.project = "test", + .label = "Function", + .name = "new_helper", + .qualified_name = "test.new_helper", + .file_path = "main.go", + .properties_json = "{}"}, + }; + cbm_store_file_delta_t first_delta = {.project = "test", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .nodes = first_nodes, + .node_count = 2}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &first_delta, first_overlay), + CBM_STORE_OK); + + ASSERT_EQ(cbm_store_get_overlay_node_view_summary(s, "test", &summary), CBM_STORE_OK); + ASSERT_EQ(summary.overlay_ready_generations, 1); + ASSERT_EQ(summary.active_file_tombstones, 1); + ASSERT_EQ(summary.canonical_nodes_visible, 1); + ASSERT_EQ(summary.overlay_owned_nodes_visible, 2); + ASSERT_EQ(summary.total_nodes_visible, 3); + + int64_t second_overlay = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, &second_overlay), + CBM_STORE_OK); + cbm_node_t second_node = {.project = "test", + .label = "Function", + .name = "newer_main", + .qualified_name = "test.newer_main", + .file_path = "main.go", + .properties_json = "{}"}; + cbm_store_file_delta_t second_delta = {.project = "test", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .nodes = &second_node, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &second_delta, second_overlay), + CBM_STORE_OK); + + ASSERT_EQ(cbm_store_get_overlay_node_view_summary(s, "test", &summary), CBM_STORE_OK); + ASSERT_EQ(summary.overlay_ready_generations, 2); + ASSERT_EQ(summary.active_file_tombstones, 1); + ASSERT_EQ(summary.canonical_nodes_visible, 1); + ASSERT_EQ(summary.overlay_owned_nodes_visible, 1); + ASSERT_EQ(summary.total_nodes_visible, 2); + + cbm_store_close(s); + PASS(); +} + TEST(store_owner_metadata_crud) { cbm_store_t *s = cbm_store_open_memory(); ASSERT_NOT_NULL(s); @@ -4125,6 +4214,7 @@ SUITE(store_nodes) { RUN_TEST(store_overlay_file_delta_publish_rows_and_tombstone); RUN_TEST(store_overlay_file_delta_publish_rejects_invalid_delta_without_rows); RUN_TEST(store_overlay_file_delta_publish_rejects_failed_generation); + RUN_TEST(store_overlay_node_view_summary_counts_latest_ready_overlay); RUN_TEST(store_owner_metadata_crud); RUN_TEST(store_rebuild_file_delta_owners_derives_from_graph); RUN_TEST(store_import_export_metadata_crud);