feat(store): add import metadata helpers

Add symbol_exports and import_refs store APIs for exact-delta planning, including export listing and importer path lookup through indexed metadata.

Keep the slice additive: no pipeline wiring, no incremental default change, no MCP or CLI API change, and no canonical graph schema behavior change.

Validation: make -f Makefile.cbm build/c/test-runner; CBM_ONLY_SUITE=store_nodes ./build/c/test-runner; CBM_ONLY_SUITE=store_search ./build/c/test-runner; CBM_ONLY_SUITE=store_bulk ./build/c/test-runner; CBM_ONLY_SUITE=sqlite_writer ./build/c/test-runner; CBM_ONLY_SUITE=tool_consolidation ./build/c/test-runner; git diff --check; bash scripts/check-source-safety.sh.
Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
This commit is contained in:
Andrew Hundt
2026-06-30 11:29:52 -04:00
parent 9448bd94fe
commit 2064d2006c
3 changed files with 326 additions and 0 deletions
+191
View File
@@ -149,6 +149,13 @@ struct cbm_store {
sqlite3_stmt *stmt_upsert_edge_owner;
sqlite3_stmt *stmt_delete_node_owners_by_file;
sqlite3_stmt *stmt_delete_edge_owners_by_file;
sqlite3_stmt *stmt_upsert_symbol_export;
sqlite3_stmt *stmt_delete_symbol_exports_by_file;
sqlite3_stmt *stmt_list_symbol_exports_by_file;
sqlite3_stmt *stmt_upsert_import_ref;
sqlite3_stmt *stmt_delete_import_refs_by_file;
sqlite3_stmt *stmt_list_import_ref_paths_by_target;
sqlite3_stmt *stmt_list_import_ref_paths_for_export_file;
};
/* ── Public accessor ────────────────────────────────────────────── */
@@ -222,6 +229,43 @@ static sqlite3_stmt *prepare_cached(cbm_store_t *s, sqlite3_stmt **slot, const c
return *slot;
}
static int store_collect_text_column(cbm_store_t *s, sqlite3_stmt *stmt, const char *op,
char ***out, int *count) {
if (!out || !count || !stmt) {
return CBM_STORE_ERR;
}
*out = NULL;
*count = 0;
int cap = ST_INIT_CAP_8;
int n = 0;
char **arr = malloc((size_t)cap * sizeof(char *));
int rc = SQLITE_OK;
while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
const char *text = (const char *)sqlite3_column_text(stmt, 0);
if (!text) {
continue;
}
if (n >= cap) {
cap *= ST_GROWTH;
arr = safe_realloc(arr, (size_t)cap * sizeof(char *));
}
arr[n++] = heap_strdup(text);
}
if (rc != SQLITE_DONE) {
for (int i = 0; i < n; i++) {
free(arr[i]);
}
free(arr);
store_set_error_sqlite(s, op);
return CBM_STORE_ERR;
}
*out = arr;
*count = n;
return CBM_STORE_OK;
}
/* Get ISO-8601 timestamp. */
static void iso_now(char *buf, size_t sz) {
time_t t = time(NULL);
@@ -949,6 +993,13 @@ void cbm_store_close(cbm_store_t *s) {
finalize_stmt(&s->stmt_upsert_edge_owner);
finalize_stmt(&s->stmt_delete_node_owners_by_file);
finalize_stmt(&s->stmt_delete_edge_owners_by_file);
finalize_stmt(&s->stmt_upsert_symbol_export);
finalize_stmt(&s->stmt_delete_symbol_exports_by_file);
finalize_stmt(&s->stmt_list_symbol_exports_by_file);
finalize_stmt(&s->stmt_upsert_import_ref);
finalize_stmt(&s->stmt_delete_import_refs_by_file);
finalize_stmt(&s->stmt_list_import_ref_paths_by_target);
finalize_stmt(&s->stmt_list_import_ref_paths_for_export_file);
/* Use sqlite3_close_v2 — auto-deallocates when last statement finalizes.
* Prevents ASan false-positive leaks from sqlite3 internal state. */
@@ -2020,6 +2071,146 @@ int cbm_store_delete_edge_owners_by_file(cbm_store_t *s, const char *project,
return CBM_STORE_OK;
}
int cbm_store_upsert_symbol_export(cbm_store_t *s, const char *project,
const char *qualified_name, const char *rel_path,
int64_t node_id, int64_t generation) {
sqlite3_stmt *stmt = prepare_cached(
s, &s->stmt_upsert_symbol_export,
"INSERT INTO symbol_exports (project, qualified_name, rel_path, node_id, generation) "
"VALUES (?1, ?2, ?3, ?4, ?5) "
"ON CONFLICT(project, qualified_name, rel_path) DO UPDATE SET "
"node_id=?4, generation=?5;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, ST_COL_1, project);
bind_text(stmt, ST_COL_2, qualified_name);
bind_text(stmt, ST_COL_3, rel_path);
if (node_id > CBM_STORE_NO_NODE_ID) {
sqlite3_bind_int64(stmt, ST_COL_4, node_id);
} else {
sqlite3_bind_null(stmt, ST_COL_4);
}
sqlite3_bind_int64(stmt, ST_COL_5, generation);
if (sqlite3_step(stmt) != SQLITE_DONE) {
store_set_error_sqlite(s, "upsert_symbol_export");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
int cbm_store_delete_symbol_exports_by_file(cbm_store_t *s, const char *project,
const char *rel_path) {
sqlite3_stmt *stmt =
prepare_cached(s, &s->stmt_delete_symbol_exports_by_file,
"DELETE FROM symbol_exports WHERE project = ?1 AND rel_path = ?2;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, ST_COL_1, project);
bind_text(stmt, ST_COL_2, rel_path);
if (sqlite3_step(stmt) != SQLITE_DONE) {
store_set_error_sqlite(s, "delete_symbol_exports_by_file");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
int cbm_store_list_symbol_exports_by_file(cbm_store_t *s, const char *project,
const char *rel_path, char ***out, int *count) {
sqlite3_stmt *stmt = prepare_cached(
s, &s->stmt_list_symbol_exports_by_file,
"SELECT qualified_name FROM symbol_exports "
"WHERE project = ?1 AND rel_path = ?2 ORDER BY qualified_name;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, ST_COL_1, project);
bind_text(stmt, ST_COL_2, rel_path);
return store_collect_text_column(s, stmt, "list_symbol_exports_by_file", out, count);
}
int cbm_store_upsert_import_ref(cbm_store_t *s, const char *project, const char *rel_path,
const char *import_text, const char *local_name,
const char *target_qn, int64_t generation) {
sqlite3_stmt *stmt =
prepare_cached(s, &s->stmt_upsert_import_ref,
"INSERT INTO import_refs (project, rel_path, import_text, local_name, "
"target_qn, generation) VALUES (?1, ?2, ?3, ?4, ?5, ?6) "
"ON CONFLICT(project, rel_path, import_text, local_name) DO UPDATE SET "
"target_qn=?5, generation=?6;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, ST_COL_1, project);
bind_text(stmt, ST_COL_2, rel_path);
bind_text(stmt, ST_COL_3, import_text);
bind_text(stmt, ST_COL_4, safe_str(local_name));
bind_text(stmt, ST_COL_5, safe_str(target_qn));
sqlite3_bind_int64(stmt, ST_COL_6, generation);
if (sqlite3_step(stmt) != SQLITE_DONE) {
store_set_error_sqlite(s, "upsert_import_ref");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
int cbm_store_delete_import_refs_by_file(cbm_store_t *s, const char *project,
const char *rel_path) {
sqlite3_stmt *stmt =
prepare_cached(s, &s->stmt_delete_import_refs_by_file,
"DELETE FROM import_refs WHERE project = ?1 AND rel_path = ?2;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, ST_COL_1, project);
bind_text(stmt, ST_COL_2, rel_path);
if (sqlite3_step(stmt) != SQLITE_DONE) {
store_set_error_sqlite(s, "delete_import_refs_by_file");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
int cbm_store_list_import_ref_paths_by_target(cbm_store_t *s, const char *project,
const char *target_qn, char ***out,
int *count) {
sqlite3_stmt *stmt =
prepare_cached(s, &s->stmt_list_import_ref_paths_by_target,
"SELECT DISTINCT rel_path FROM import_refs "
"WHERE project = ?1 AND target_qn = ?2 ORDER BY rel_path;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, ST_COL_1, project);
bind_text(stmt, ST_COL_2, target_qn);
return store_collect_text_column(s, stmt, "list_import_ref_paths_by_target", out, count);
}
int cbm_store_list_import_ref_paths_for_export_file(cbm_store_t *s, const char *project,
const char *export_rel_path, char ***out,
int *count) {
sqlite3_stmt *stmt = prepare_cached(
s, &s->stmt_list_import_ref_paths_for_export_file,
"SELECT DISTINCT i.rel_path FROM import_refs i "
"JOIN symbol_exports e ON e.project = i.project AND e.qualified_name = i.target_qn "
"WHERE e.project = ?1 AND e.rel_path = ?2 AND i.rel_path != e.rel_path "
"ORDER BY i.rel_path;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, ST_COL_1, project);
bind_text(stmt, ST_COL_2, export_rel_path);
return store_collect_text_column(s, stmt, "list_import_ref_paths_for_export_file", out, count);
}
/* ── FindNodesByFileOverlap ─────────────────────────────────────── */
int cbm_store_find_nodes_by_file_overlap(cbm_store_t *s, const char *project, const char *file_path,
+29
View File
@@ -29,6 +29,7 @@ typedef struct cbm_store cbm_store_t;
#define CBM_STORE_INDEX_STATUS_COMPLETE "complete"
#define CBM_STORE_DERIVED_STATUS_STALE "stale"
#define CBM_STORE_DERIVED_KIND_DIRECT "direct"
#define CBM_STORE_NO_NODE_ID 0
/* ── Data structures ────────────────────────────────────────────── */
@@ -454,6 +455,34 @@ int cbm_store_delete_node_owners_by_file(cbm_store_t *s, const char *project,
int cbm_store_delete_edge_owners_by_file(cbm_store_t *s, const char *project,
const char *rel_path);
int cbm_store_upsert_symbol_export(cbm_store_t *s, const char *project,
const char *qualified_name, const char *rel_path,
int64_t node_id, int64_t generation);
int cbm_store_delete_symbol_exports_by_file(cbm_store_t *s, const char *project,
const char *rel_path);
/* Caller frees each returned string and the array. */
int cbm_store_list_symbol_exports_by_file(cbm_store_t *s, const char *project,
const char *rel_path, char ***out, int *count);
int cbm_store_upsert_import_ref(cbm_store_t *s, const char *project, const char *rel_path,
const char *import_text, const char *local_name,
const char *target_qn, int64_t generation);
int cbm_store_delete_import_refs_by_file(cbm_store_t *s, const char *project,
const char *rel_path);
/* Caller frees each returned string and the array. */
int cbm_store_list_import_ref_paths_by_target(cbm_store_t *s, const char *project,
const char *target_qn, char ***out,
int *count);
/* Caller frees each returned string and the array. */
int cbm_store_list_import_ref_paths_for_export_file(cbm_store_t *s, const char *project,
const char *export_rel_path, char ***out,
int *count);
/* ── Search ─────────────────────────────────────────────────────── */
int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_search_output_t *out);
+106
View File
@@ -38,6 +38,16 @@ static int store_count_metadata_owners(cbm_store_t *s, int edge, const char *pro
return count;
}
static void store_free_string_array(char **items, int count) {
if (!items) {
return;
}
for (int i = 0; i < count; i++) {
free(items[i]);
}
free(items);
}
TEST(store_open_memory) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
@@ -691,6 +701,101 @@ TEST(store_owner_metadata_crud) {
PASS();
}
TEST(store_import_export_metadata_crud) {
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 node = {.project = "test",
.label = "Function",
.name = "Helper",
.qualified_name = "test.lib.Helper",
.file_path = "lib.go",
.properties_json = "{}"};
int64_t node_id = cbm_store_upsert_node(s, &node);
ASSERT_GT(node_id, 0);
ASSERT_EQ(cbm_store_upsert_symbol_export(s, "test", "test.lib.Helper", "lib.go", node_id, 1),
CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_symbol_export(s, "test", "test.lib.Unresolved", "lib.go",
CBM_STORE_NO_NODE_ID, 1),
CBM_STORE_OK);
char **items = NULL;
int count = 0;
ASSERT_EQ(cbm_store_list_symbol_exports_by_file(s, "test", "lib.go", &items, &count),
CBM_STORE_OK);
ASSERT_EQ(count, 2);
ASSERT_STR_EQ(items[0], "test.lib.Helper");
ASSERT_STR_EQ(items[1], "test.lib.Unresolved");
store_free_string_array(items, count);
ASSERT_EQ(cbm_store_upsert_import_ref(s, "test", "caller.go", "test.lib", "Helper",
"test.lib.Helper", 1),
CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_import_ref(s, "test", "other.go", "test.other", "Other",
"test.other.Other", 1),
CBM_STORE_OK);
items = NULL;
count = 0;
ASSERT_EQ(cbm_store_list_import_ref_paths_by_target(s, "test", "test.lib.Helper", &items,
&count),
CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(items[0], "caller.go");
store_free_string_array(items, count);
items = NULL;
count = 0;
ASSERT_EQ(cbm_store_list_import_ref_paths_for_export_file(s, "test", "lib.go", &items,
&count),
CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(items[0], "caller.go");
store_free_string_array(items, count);
ASSERT_EQ(cbm_store_upsert_import_ref(s, "test", "caller.go", "test.lib", "Helper",
"test.lib.Renamed", 2),
CBM_STORE_OK);
items = NULL;
count = 0;
ASSERT_EQ(cbm_store_list_import_ref_paths_by_target(s, "test", "test.lib.Helper", &items,
&count),
CBM_STORE_OK);
ASSERT_EQ(count, 0);
store_free_string_array(items, count);
items = NULL;
count = 0;
ASSERT_EQ(cbm_store_list_import_ref_paths_by_target(s, "test", "test.lib.Renamed", &items,
&count),
CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(items[0], "caller.go");
store_free_string_array(items, count);
ASSERT_EQ(cbm_store_delete_import_refs_by_file(s, "test", "caller.go"), CBM_STORE_OK);
items = NULL;
count = 0;
ASSERT_EQ(cbm_store_list_import_ref_paths_by_target(s, "test", "test.lib.Renamed", &items,
&count),
CBM_STORE_OK);
ASSERT_EQ(count, 0);
store_free_string_array(items, count);
ASSERT_EQ(cbm_store_delete_symbol_exports_by_file(s, "test", "lib.go"), CBM_STORE_OK);
items = NULL;
count = 0;
ASSERT_EQ(cbm_store_list_symbol_exports_by_file(s, "test", "lib.go", &items, &count),
CBM_STORE_OK);
ASSERT_EQ(count, 0);
store_free_string_array(items, count);
cbm_store_close(s);
PASS();
}
/* ── Properties JSON round-trip ─────────────────────────────────── */
TEST(store_node_properties_json) {
@@ -1802,6 +1907,7 @@ SUITE(store_nodes) {
RUN_TEST(store_file_hash_upsert_rejects_null_required_fields);
RUN_TEST(store_file_state_crud);
RUN_TEST(store_owner_metadata_crud);
RUN_TEST(store_import_export_metadata_crud);
RUN_TEST(store_node_properties_json);
RUN_TEST(store_node_null_properties);
RUN_TEST(store_find_by_file_overlap);